Java并发学习(一) ----线程和进程

206 阅读22分钟

Java并发学习(一) ----线程和进程

  • 带着常见的面试题去学习
  • 线程和进程的概念和区别
  • 多线程的优点和缺点
  • 线程的生命周期(状态)
  • 如何创建一个线程
  • 线程中的方法及其解析

带着常见的面试题去学习

  • 进程和线程的概念和区别?
  • 多线程开发的优点以及缺点?
  • Java中线程的状态以及转换?
  • 如何实现线程,线程中有哪些方法?
  • 调用 start() 方法和 run() 的区别?
  • sleep() 与 wait() 的区别?
  • notify() 与 notifyAll() 的区别?
  • 如何正常结束一个线程?
  • 就绪状态和阻塞状态有什么区别?可以互相装换吗?

线程和进程的概念和区别

概念:

进程 :进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是操作系统进行资源分配和调度的基本单位。每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程是重量级的,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。

线程:线程是依附于进程的,是 CPU 调度和分派的基本单位,它是比进程更小的能独立运行的基本单位.线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。

区别:

根本区别:进程是操作系统资源分配的基本单位,而线程是 CPU 调度和分派的基本单位

资源开销:每个进程都有独立的代码和数据空间(通称为内存空间),进程之间的切换会有较大的开销(涉及到内核态和用户态);线程可以看做轻量级的进程,同一进程下的所有线程共享进程的内存空间,但每个线程都有自己独立的程序计数器,一组寄存器和栈,线程之间切换的开销小。创建进程开销比创建线程的开销要大。

包含关系:一个进程内可以有多个线程,多个线程之间是并发执行的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。

内存分配:同一进程的线程共享本进程的地址空间和资源,而进程之间的地址空间和资源是相互独立的

影响关系:一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮。

执行过程:每个独立的进程有程序运行的入口、顺序执行序列和程序出口。但是线程不能独立执行,必须依存在一个进程中,由进程提供多个线程执行控制,两者均可并发执行。

多线程开发的优点和缺点

优点:

1:资源利用率更高

例如从磁盘读取文件的时候,大部分的 CPU 时间是用于等待磁盘去读取数据,在这段时间里, CPU 非常空闲,通过改变操作的顺序,就能更好的使用 CPU 资源

2.程序设计更简单

在单线程应用程序中,如果你想编写程序手动处理多个IO的读取和处理的顺序,你必须记录每个文件读取和处理的状态。相反,你可以启动两个线程,每个线程处理一个文件的读取和处理操作。线程会在等待磁盘读取文件的过程中被阻塞。在等待的时候,其他的线程能够使用CPU去处理已经读取完的文件。其结果就是,磁盘总是在繁忙地读取不同的文件到内存中。这会带来磁盘和CPU利用率的提升。而且每个线程只需要记录一个文件,因此这种方式也很容易编程实现。

3:程序响应更快

例如一个人服务器程序是单线程的,此时如果一个请求需要占用大量的时间在这段时间内新的客户端就无法发送请求给服务器端,如果引入多线程则会大大提高效率。

缺点:

1:设计有时会更复杂:多线程程序在访问共享可变数据的时候往往需要我们很小心的处理,否则就会出现难以发现的 BUG ,一般地,多线程程序往往比单线程程序设计会更加复杂(尽管有些单线程处理程序可能比多线程程序要复杂),而且错误很难重现(因为线程调度的无序性,某些 bug 的出现依赖于某种特定的线程执行时序)。

2:上下文切换的开销:当 CPU 从执行一个线程切换到另外一个线程的时候,它需要存储当前线程的本地数据,程序指针等,然后载入另一个线程的本地数据,程序指针等,最后才开始执行,这种切换称为“上下文切换”CPU会在一个上下文中执行一个线程,然后切换到另外一个上下文中执行另一个线程。上下文的切换非常耗费系统资源。如果没有必要,应该减少上下文切换的发生。

3:增加资源消耗:线程在运行的时候,需要从计算机里得到一些资源,除了 CPU ,线程还需要一些内存来维持它本地的堆栈,还需要占用操作系统中的一些资源来管理线程。

4:线程的死锁:较长时间等待或资源竞争以及死锁等多线程症状。

5:对共有变量的同时读写:当多个线程需要对共有变量进行写的操作时,后一个线程往往会修改掉前一个线程存放的数据,从而使前一个线程的参数被修改,另外当共有变量的读写操作是非原子性时,在不同的机器上是不确定的,中断时间的不确定性,会导致数据在一个线程内的操作产生错误,从而产生莫名其妙的错误,而这种错误是程序员无法预知的

线程的生命周期(状态)

在Java中线程的生命周期主要有以下六种状态:

  • New(新创建)
  • Runnable(可运行)
  • Blocked(被阻塞)
  • Waiting(等待)
  • Timed Waiting(计时等待)
  • Terminated(被终止)

New 新建状态

  • 首先我们展示一下整个线程状态的转换流程图,下面我们将进行详细的介绍讲解,如下图所示,我们可以直观的看到六种状态的转换,首先左侧上方是 NEW 状态,这是创建新线程的状态,相当于我们 new Thread() 的过程。

img

  • New 表示线程被创建但尚未启动的状态:当我们用 new Thread() 新建一个线程时,如果线程没有开始运行 start() 方法,那么线程也就没有开始执行 run() 方法里面的代码,那么此时它的状态就是 New。而一旦线程调用了 start(),它的状态就会从 New 变成 Runnable,进入到图中绿色的方框

Runnable 可运行状态

  • Java 中的 Runable 状态对应操作系统线程状态中的两种状态,分别是 Running Ready,也就是说,Java 中处于 Runnable 状态的线程有可能正在执行,也有可能没有正在执行,正在等待被分配 CPU 资源。

  • 所以,如果一个正在运行的线程是 Runnable 状态,当它运行到任务的一半时,执行该线程的 CPU 被调度去做其他事情,导致该线程暂时不运行,它的状态依然不变,还是 Runnable,因为它有可能随时被调度回来继续执行任务。

阻塞状态

  • 上面认识了线程的关键状态 Runnable ,那么接下来我们来看一下下面的三个状态,这三个状态我们可以统称为阻塞状态,它们分别是 Blocked(被阻塞)Waiting(等待)Timed Waiting(计时等待) .

Blocked 被阻塞状态

  • 首先我们来认识一下 Blocked 状态,这是一个相对简单的状态,我们可以通过下面的图示看到,从 Runnable 状态进入到 Blocked 状态只有一种途径,那么就是当进入到 synchronized 代码块中时未能获得相应的 monitor 锁( synchronized 的实现都是基于 monitor 锁的),

img

  • 在右侧我们可以看到,有连接线从 Blocked 状态指向了 Runnable ,也只有一种情况,那么就是当线程获得 monitor 锁,此时线程就会进入 Runnable 状体中参与 CPU 资源的抢夺

Waiting 等待状态

上面我们看完阻塞状态,那么接下来我们了解一下 Waiting 状态,对于 Waiting 状态的进入有三种情况,如下图中所示,分别为:

  • 当线程中调用了没有设置 Timeout 参数的 Object.wait() 方法
  • 当线程调用了没有设置 Timeout 参数的 Thread.join() 方法
  • 当线程调用了 LockSupport.park() 方法

Waiting 等待状态

关于 LockSupport.park() 方法,这里说一下,我们通过上面知道 Blocked 是针对 synchronized monitor 锁的,但是在 Java 中实际是有很多其他锁的,比如 ReentrantLock 等,在这些锁中,如果线程没有获取到锁则会直接进入 Waiting 状态,其实这种本质上它就是执行了 LockSupport.park() 方法进入了Waiting 状态

  • Blocked Waiting 的区别
    • Blocked状态一般是在线程进入 synchronized 方法或者代码块失败(未获得 monitor 锁),线程进入同步队列
    • Waiting 则进入等待队列等待 join 的线程执行完毕,或者是 notify()/notifyAll()才有机会获取CPU的时间片来继续执行
  • 等待队列和同步队列
    • 同步队列(锁池):假设线程A已经拥有了某个对象(注意:不是类)的锁,而其它的线程想要调用这个对象的某个 synchronized 方法(或者 synchronized 语句块),由于这些线程在进入对象的 synchronized 方法之前必须先获得该对象的锁的拥有权,但是该对象的锁目前正被线程A拥有,所以这些线程就进入了该对象的同步队列(锁池)中,这些线程状态为Blocked
    • 等待队列(等待池):假设一个线程A调用了某个对象的 wait() 方法,线程A就会释放该对象的锁(因为 wait() 方法必须出现在 synchronized 中,这样自然在执行 wait() 方法之前线程A就已经拥有了该对象的锁),同时 线程A就进入到了该对象的等待队列(等待池)中,此时线程A状态为Waiting。如果另外的一个线程调用了相同对象的 notifyAll() 方法,那么 处于该对象的等待池中的线程就会全部进入该对象的同步队列(锁池)中,准备争夺锁的拥有权。如果另外的一个线程调用了相同对象的 notify() 方法,那么 仅仅有一个处于该对象的等待池中的线程(随机)会进入该对象的同步队列(锁池)

Timed Waiting 计时等待状态

  • 最后我们来说说这个 Timed Waiting 状态,它与 Waiting 状态非常相似,其中的区别只在于是否有时间的限制,在 Timed Waiting 状态时会等待超时,之后由系统唤醒,或者也可以提前被通知唤醒如 notify()

img

通过上述图我们可以看到在以下情况会让线程进入 Timed Waiting 状态。

  • 线程执行了设置了时间参数的 Thread.sleep(long millis) 方法;
  • 线程执行了设置了时间参数的 Object.wait(long timeout) 方法;
  • 线程执行了设置了时间参数的 Thread.join(long millis) 方法;
  • 线程执行了设置了时间参数的 LockSupport.parkNanos(long nanos) 方法和 LockSupport.parkUntil(long deadline) 方法。

通过这个我们可以进一步看到它与 waiting 状态的相同

线程状态间转换

上面我们讲了各自状态的特点和运行状态进入相应状态的情况 ,那么接下来我们将来分析各自状态之间的转换,其实主要就是 BlockedwaitingTimed Waiting 三种状态的转换 ,以及他们是如何进入下一状态最终进入 Runnable

Blocked 进入 Runnable

  • 想要从 Blocked 状态进入 Runnable 状态,我们上面说过必须要线程获得 monitor 锁,但是如果想进入其他状态那么就相对比较特殊,因为它是没有超时机制的,也就是不会主动进入。

如下图中紫色加粗表示线路: img

Waiting 进入 Runnable

  • 只有当执行了 LockSupport.unpark(),或者 join 的线程运行结束,或者被中断时才可以进入 Runnable 状态。
  • 如下图标注

img

  • 如果通过其他线程调用 notify()notifyAll()来唤醒它,则它会直接进入 Blocked 状态,这里大家可能会有疑问,不是应该直接进入 Runnable 吗?这里需要注意一点 ,因为唤醒 Waiting 线程的线程如果调用 notify()notifyAll(),要求必须首先持有该 monitor 锁,这也就是我们说的 wait()notify 必须在 synchronized 代码块中。
  • 所以处于 Waiting 状态的线程被唤醒时拿不到该锁,就会进入 Blocked 状态,直到执行了 notify()/notifyAll() 的唤醒它的线程执行完毕并释放 monitor 锁,才可能轮到它去抢夺这把锁,如果它能抢到,就会从 Blocked 状态回到 Runnable 状态。

img

这里大家一定要注意这点,当我们通过 notify 唤醒时,是先进入阻塞状态的 ,再等抢夺到 monitor 锁喉才会进入 Runnable 状态!

Timed Waiting 进入 Runnable

  • 同样在 Timed Waiting 中执行 notify()notifyAll() 也是一样的道理,它们会先进入 Blocked 状态,然后抢夺锁成功后,再回到 Runnable 状态。

img

  • 但是对于 Timed Waiting 而言,它存在超时机制,也就是说如果超时时间到了那么就会系统自动直接拿到锁,或者当 join 的线程执行结束/调用了LockSupport.unpark()/被中断等情况都会直接进入 Runnable 状态,而不会经历 Blocked 状态

img

Terminated 终止

最后我们来说最后一种状态,Terminated 终止状态,要想进入这个状态有两种可能。

  • run() 方法执行完毕,线程正常退出。

  • 使用退出标志退出线程。一般 run 方法执行完,线程就会正常结束。然而,常常有些线程是服务线程。它们需要长时间的运行,只有在外部某些条件满足的情况下,才能关闭这些线程。

    可以使用一个变量来控制循环,最直接的方法就是设一个 boolean 类型的标志,并通过设置这个标志为 true或 false 来控制 while 循环是否退出。

    定义了一个退出标志 exit,当 exit 为 true 时,while 循环退出,exit 的默认值为 false。在定义 exit 时,使用了一个 Java 关键字 volatile,这个关键字的目的是使 exit 同步,也就是说在同一时刻只能由一个线程来修改 exit 的值。

    public class ThreadTerminated extends Thread {
        public volatile boolean exit = false;
        public void run() {
            while (!exit){
                return;
            }
        }
    }
    
    
  • Interrupte() 方法结束线程。使用 interrupt 方法来中断线程有两种情况:

    • 线程处于阻塞状态:如使用了 sleep(),同步锁的 wait(),socket 中的 receiver()/accept() 等方法时,会使线程处于阻塞状态。当调用线程的 interrupt 方法时,会抛出 InterruptException 异常。阻塞中的那个方法抛出这个异常,通过代码捕获该异常,然后 break 跳出循环状态,从而让我们有机会结束这个线程的执行。通常很多人认为只要调用 interrupt 方法线程就会结束,实际上是错的, 一定要先捕获 InterruptedException 异常之后通过 break 来跳出循环,才能正常结束 run 方法。

    • 线程未处于阻塞状态:使用 isInterrupted() 判断线程的中断标志来退出循环。当使用 interrupt() 方法时,中断标志就会置 true,和使用自定义的标志来控制循环是一样的道理。

    public class ThreadInterrupt extends Thread {
        public void run() {
            while (!isInterrupted()) {  //非阻塞过程中通过判断中断标志来退出
                try {
                    Thread.sleep(5 * 1000);  // 阻塞过程捕获中断异常来退出
                } catch (InterruptedException e) {
                    e.printStackTrace();
                    break;  // 捕获到异常之后,执行 break 跳出循环
                }
       
           }
     }
    
  • stop()方法终止线程,不安全。

如何创建一个线程

Java中创建线程主要有三种方式:

一、继承Thread类创建线程类

(1)定义Thread类的子类,并重写该类的run方法,该run方法的方法体就代表了线程要完成的任务。因此把run()方法称为执行体。

(2)创建Thread子类的实例,即创建了线程对象。

(3)调用线程对象的start()方法来启动该线程。

package com.thread;

public class FirstThreadTest extends Thread{
	int i = 0;
	//重写run方法,run方法的方法体就是现场执行体
	public void run(){
		for(;i<100;i++){
		System.out.println(getName()+"  "+i);
		}
	}
    
	public static void main(String[] args){
		for(int i = 0;i< 100;i++){
			System.out.println(Thread.currentThread().getName()+"  : "+i);
			if(i==20)
			{
				new FirstThreadTest().start();
				new FirstThreadTest().start();
			}
		}
	}
}

上述代码中Thread.currentThread()方法返回当前正在执行的线程对象。GetName()方法返回调用该方法的线程的名字。

二、通过Runnable接口创建线程类

(1)定义runnable接口的实现类,并重写该接口的run()方法,该run()方法的方法体同样是该线程的线程执行体。

(2)创建 Runnable实现类的实例,并依此实例作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象。

(3)调用线程对象的start()方法来启动该线程。

示例代码为:

package com.thread;
public class RunnableThreadTest implements Runnable{
	private int i;
	public void run(){
		for(i = 0;i <100;i++){
			System.out.println(Thread.currentThread().getName()+" "+i);
		}
	}
	public static void main(String[] args){
		for(int i = 0;i < 100;i++){
			System.out.println(Thread.currentThread().getName()+" "+i);
			if(i==20){
				RunnableThreadTest rtt = new RunnableThreadTest();
				new Thread(rtt,"新线程1").start();
				new Thread(rtt,"新线程2").start();
			}
		}
	}
}

三、通过Callable和Future创建线程

(1)创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,并且有返回值。

(2)创建Callable实现类的实例,使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。

(3)使用FutureTask对象作为Thread对象的target创建并启动新线程。

(4)调用FutureTask对象的get()方法来获得子线程执行结束后的返回值

实例代码:

package com.thread;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

public class CallableThreadTest implements Callable<Integer>{
	public static void main(String[] args){
		CallableThreadTest ctt = new CallableThreadTest();
		FutureTask<Integer> ft = new FutureTask<>(ctt);
		for(int i = 0;i < 100;i++){
			System.out.println(Thread.currentThread().getName()+" 的循环变量i的值"+i);
			if(i==20){
				new Thread(ft,"有返回值的线程").start();
			}
		}
		try{
			System.out.println("子线程的返回值:"+ft.get());
		} catch (InterruptedException e){
			e.printStackTrace();
		} catch (ExecutionException e){
			e.printStackTrace();
		}
	}
	@Override
	public Integer call() throws Exception{
		int i = 0;
		for(;i<100;i++){
			System.out.println(Thread.currentThread().getName()+" "+i);
		}
		return i;
	}
}

创建线程的三种方式的对比

采用实现Runnable、Callable接口的方式创见多线程时,优点是:线程类只是实现了Runnable接口或Callable接口,还可以继承其他类。在这种方式下,多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。 缺点是:编程稍微复杂,如果要访问当前线程,则必须使用Thread.currentThread()方法。 使用继承Thread类的方式创建多线程时优点是:编写简单,如果需要访问当前线程,则无需使用Thread.currentThread()方法,直接使用this即可获得当前线程。 缺点是:线程类已经继承了Thread类,所以不能再继承其他父类。

线程中的方法及其解析

下面是初步描述:

序号方法描述
1public void start() 使该线程开始执行;Java 虚拟机调用该线程的 run 方法。
2public void run() 如果该线程是使用独立的 Runnable 运行对象构造的,则调用该 Runnable 对象的 run 方法;否则,该方法不执行任何操作并返回。
3public final void setName(String name) 改变线程名称,使之与参数 name 相同。
4public final void setPriority(int priority) 更改线程的优先级。
5public final void setDaemon(boolean on) 将该线程标记为守护线程或用户线程。
6public final void join(long millisec) 等待该线程终止的时间最长为 millis 毫秒。t.join()方法只会使主线程(或者说调用t.join()的线程)进入等待池并等待t线程执行完毕后才会被唤醒。并不影响同一时刻处在运行状态的其他线程。
7public void interrupt() 中断线程。
8public final boolean isAlive() 测试线程是否处于活动状态。

上述方法是被Thread对象调用的。下面的方法是Thread类的静态方法。sleep是静态方法,最好不要用Thread的实例对象调用它,因为它睡眠的始终是当前正在运行的线程,而不是调用它的线程对象,它只对正在运行状态的线程对象有效。

序号方法描述
1public static void yield() 暂停当前正在执行的线程对象,并执行其他线程。
2public static void sleep(long millisec) 在指定的毫秒数内让当前正在执行的线程休眠(暂停执行),此操作受到系统计时器和调度程序精度和准确性的影响。
3public static boolean holdsLock(Object x) 当且仅当当前线程在指定的对象上保持监视器锁时,才返回 true。
4public static Thread currentThread() 返回对当前正在执行的线程对象的引用。
5public static void dumpStack() 将当前线程的堆栈跟踪打印至标准错误流。

通过问题对几个重要方法的详细分析:

1.sleep() 和 wait() 的区别?

对于 sleep() 方法,我们首先要知道该方法是属于 Thread 类中的。而 wait() 方法,则是属于 Object 类中的。

sleep() 方法导致了程序暂停执行指定的时间,让出cpu该其他线程,但是他的监控状态依然保持者,当指定的时间到了又会自动恢复运行状态。在调用sleep()方法的过程中,线程不会释放对象锁。

而当调用wait()方法的时候,线程会放弃对象锁,进入等待此对象的等待队列,当针对此对象调用notifyAll()或notify()方法后本线程才进入对象锁定池准备获取对象锁进入运行状态(注意notify()和notifyAll()的区别)

2.notify()和notifyAll()的区别?

  • 如果线程调用了对象的 wait()方法,那么线程便会处于该对象的等待队列中,等待队列中的线程不会去竞争该对象的锁

  • 当有线程调用了对象的 notifyAll()方法(唤醒所有 wait 线程)或 notify()方法(只随机唤醒一个 wait 线程),被唤醒的的线程便会进入该对象的同步队列中,同步队列中的线程会去竞争该对象锁。也就是说,调用了notify后只有一个线程会由等待队列进入同步队列,而notifyAll会将该对象等待队列内的所有线程移动到同步队列池中,等待锁竞争

  • 优先级高的线程竞争到对象锁的概率大,假若某线程没有竞争到该对象锁,它还会留在同步队列中,唯有线程再次调用 wait()方法,它才会重新回到等待队列中。而竞争到对象锁的线程则继续往下执行,直到执行完了 synchronized 代码块,它会释放掉该对象锁,这时同步队列中的线程会继续竞争该对象锁。

3.调用start()和run()的区别?

  • 调用start()方法来启动线程,系统会把该run()方法当成线程执行体来处理。但如果直接调用线程对象的run()方法,则run()方法立即就会被执行,而且在run()方法返回之前其他线程无法并发执行。也就是说,系统把线程对象当成一个普通对象,而run()方法也就是一个它的普通方法(就相当于其的调用方法),而不是线程执行体

  • start():我们先来看看API中对于该方法的介绍:使该线程开始执行;Java 虚拟机调用该线程的 run 方法。 结果是两个线程并发地运行;当前线程(从调用返回给 start 方法)和另一个线程(执行其 run 方法)。 多次启动一个线程是非法的。特别是当线程已经结束执行后,不能再重新启动。用start方法来启动线程,真正实现了多线程运行,这时无需等待run方法体代码执行完毕而直接继续执行下面的代码。通过调用Thread类的 start()方法来启动一个线程,这时此线程处于就绪(可运行)状态,并没有运行,一旦得到cpu时间片,就开始执行run()方法,这里方法 run()称为线程体,它包含了要执行的这个线程的内容,Run方法运行结束,此线程随即终止。

  • run():我们还是先看看API中对该方法的介绍:如果该线程是使用独立的 Runnable 运行对象构造的,则调用该 Runnable 对象的 run 方法;否则,该方法不执行任何操作并返回。Thread 的子类应该重写该方法。run()方法只是类的一个普通方法而已,如果直接调用Run方法,程序中依然只有主线程这一个线程,其程序执行路径还是只有一条,还是要顺序执行,还是要等待run方法体执行完毕后才可继续执行下面的代码,这样就没有达到写线程的目的。

  • 总结:调用start方法方可启动线程,而run方法只是thread的一个普通方法调用,还是在主线程里执行。

参考文献:

1.线程的六种状态摘自:线程的六种状态

2.创建线程的三种方式摘自:Java创建线程的三种方式