Java 核心知识总结 多线程

相关概念

什么是程序、进程和线程?

  • 程序(program) :为完成特定任务,用某种语言编写的一组指令的集合 。即指一段静态的代码,静态对象。

  • 进程(process):程序的一次执行过程,或是正在内存中运行的应用程序。

    • 每个进程都有一个独立的内存空间,系统运行一个程序即是一个进程从创建、运行到消亡的过程(生命周期)。
    • 程序是静态的,进程是动态的。
    • 进程作为操作系统调度和分配资源的最小单位(亦是系统运行程序的基本单位),系统在运行时会为每个进程分配不同的内存区域。
    • 现代的操作系统大都支持多进程,支持同时运行多个程序。
  • 线程(thread) :进程可进一步细化为线程,是程序内部的一条执行路径。一个进程中至少有一个线程。

    • 一个进程同一时间若并行执行多个线程,就是支持多线程的。

    • 线程作为 CPU 调度和执行的最小单位

    • 一个进程中的多个线程共享相同的内存单元 ,它们从同一个堆中分配对象,可以访问相同的变量和对象。这就使得线程间通信更简便、高效。但多个线程操作共享的系统资源可能就会带来安全隐患

    • 下图中,红框的蓝色区域为线程独享,黄色区域为线程共享。

不同的进程之间是不共享内存的。进程之间的数据交换和通信的成本很高。

线程调度的方式

  • 分时调度: 所有线程轮流使用 CPU 的使用权,并且平均分配每个线程占用 CPU 的时间。
  • 抢占式调度: 让优先级高的线程以较大的概率优先使用 CPU。如果线程的优先级相同,那么会随机选择一个(线程随机性),Java 使用的为抢占式调度。

多线程程序的优点

以单核 CPU 为例,只使用单个线程先后完成多个任务(调用多个方法),肯定比用多个线程来完成用的时间更短,为何仍需多线程呢?

  1. 提高应用程序的响应。对图形化界面更有意义,可增强用户体验。
  2. 提高计算机系统 CPU 的利用率。
  3. 改善程序结构。将既长又复杂的进程分为多个线程,独立运行,利于理解和修改。

单核 CPU 和多核 CPU 有什么区别?

单核 CPU 在一个时间单元内只能执行一个线程的任务。单核 CPU 的代码经过一系列的前导操作,然后到 CPU 处执行时只有一个CPU,大家就排队执行。

这时候想要提升系统性能,可以提升 CPU 性能或增加 CPU 个数,即为多核的 CPU。

多核的效率是单核的倍数吗? 理论上是,但是实际不可能,至少有两方面的损耗。

  • 一个是多个核心的其他共用资源限制。譬如,4 核 CPU 对应的内存、Cache、寄存器并没有同步扩充 4 倍。
  • 另一个是多核CPU之间的协调管理损耗。譬如多个核心同时运行两个相关的任务,需要考虑任务同步,这也需要消耗额外性能。

并行与并发

  • 并行(parallel) :指在同一时刻,有多条指令在多个 CPU 上同时执行

  • 并发(concurrency) :在一段时间内,有多条指令在单个 CPU 上快速轮换、交替执行,使得在宏观上具有多个进程同时执行的效果。

在操作系统中,启动了多个程序,并发指的是在一段时间内宏观上有多个程序同时运行。

在单核 CPU 系统中,每一时刻只能有一个程序执行,即微观上这些程序是分时的交替运行。在宏观上是同时运行的,因为分时交替运行的时间非常短。

在多核 CPU 系统中,这些程序可以分配到多个 CPU 上,实现多任务并行执行,即利用每个处理器来处理一个程序。

创建和启动线程

介绍一下 Thread 类

  • Java 语言的 JVM 允许程序运行多个线程,使用 java.lang.Thread 类代表线程,所有的线程对象都必须是 Thread 类或其子类的实例。
  • Thread 类的特性
    • 每个线程都是通过某个特定 Thread 对象的 run() 方法来完成操作的,因此把 run() 方法体称为线程执行体
    • 通过该 Thread 对象的 start() 方法来启动这个线程,而非直接调用run()。
    • 要想实现多线程,必须在主线程中创建新的线程对象。

创建线程的两种方式

方式 1:继承 Thread 类

Java 通过继承 Thread 类来创建启动多线程的步骤如下:

  1. 定义 Thread 类的子类,并重写该类的 run() 方法,该 run() 方法的方法体就代表了线程需要完成的任务。
  2. 创建 Thread 子类的实例,即创建了线程对象。
  3. 调用线程对象的 start() 方法来启动该线程。
java 复制代码
class MyThread extends Thread {
    //定义指定线程名称的构造方法
    public MyThread(String name) {
        //调用父类的String参数的构造方法,指定线程的名称
        super(name);
    }

    //重写run方法,完成该线程执行的逻辑
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println(getName()+":正在执行!"+i);
        }
    }
}

public class TestMyThread {
    public static void main(String[] args) {
        //创建自定义线程对象1
        MyThread mt1 = new MyThread("子线程1");
        //开启子线程1
        mt1.start();
        
        //创建自定义线程对象2
        MyThread mt2 = new MyThread("子线程2");
        //开启子线程2
        mt2.start();
        
        //在主方法中执行for循环
        for (int i = 0; i < 10; i++) {
            System.out.println("main线程!"+i);
        }
    }
}

注意:

  1. 如果自己手动调用 run() 方法,那么就只是普通方法,没有启动多线程模式;想要启动多线程,必须调用 start() 方法。
  2. run() 方法由 JVM 调用,什么时候调用、执行的过程控制都由操作系统的 CPU 调度决定。
  3. 一个线程对象只能调用一次 start() 方法启动,如果重复调用了,则将抛出以上的异常 IllegalThreadStateException

方式 2:实现 Runnable 接口

Java 有单继承的限制,当我们无法继承 Thread 类时,可以实现 Runnable 接口,重写 run() 方法,然后再通过 Thread 类的对象代理启动和执行我们的线程体 run() 方法。

步骤如下:

  1. 定义 Runnable 接口的实现类,并重写该接口的 run() 方法,该 run() 方法的方法体同样是该线程的线程执行体。
  2. 创建 Runnable 实现类的实例,并以此实例作为 Thread 的 target 参数来创建 Thread 对象,该 Thread 对象才是真正的线程对象。
  3. 调用线程对象的 start() 方法,启动线程。调用 Runnable 接口实现类的 run 方法。
java 复制代码
class MyRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 20; i++) {
            System.out.println(Thread.currentThread().getName() + " " + i);
        }
    }
}

public class TestMyRunnable {
    public static void main(String[] args) {
        //创建自定义类对象 线程任务对象
        MyRunnable mr = new MyRunnable();
        //创建线程对象
        Thread t = new Thread(mr, "长江");
        t.start();
        for (int i = 0; i < 20; i++) {
            System.out.println("黄河 " + i);
        }
    }
}

实际上,所有的多线程代码都是通过运行 Thread 的 start() 方法来运行的。因此,不管是继承 Thread 类还是实现 Runnable 接口来实现多线程,最终还是通过 Thread 的对象的 API 来控制线程的,熟悉 Thread 类的 API 是进行多线程编程的基础。

说明:Runnable 对象仅作为 Thread 对象的 target,Runnable 实现类里包含的 run() 方法仅作为线程执行体。而实际的线程对象依然是 Thread 实例,只是该 Thread 线程负责执行其 target 的 run() 方法。

也可以使用匿名类对象创建和启动线程

java 复制代码
// 继承 Thread 类
new Thread("新的线程!"){
	@Override
	public void run() {
		for (int i = 0; i < 10; i++) {
			System.out.println(getName()+":正在执行!"+i);
		}
	}
}.start();

// 实现 Runnable 接口
new Thread(new Runnable(){
	@Override
	public void run() {
		for (int i = 0; i < 10; i++) {
			System.out.println(Thread.currentThread().getName()+":" + i);
		}
	}
}).start();

创建线程两种方式之间的联系和区别是什么

联系: Thread 类实际上也是实现了 Runnable 接口的类。

java 复制代码
 public class Thread extends Object implements Runnable{...}

区别:

  • 线程代码实现的位置不同:继承 Thread 存放在 Thread 子类 run 方法中;实现 Runnable 存在接口的子类的 run 方法。
  • 实现 Runnable 接口比继承 Thread 类所具有的优势:
    • 避免了单继承的局限性;
    • 多个线程可以共享同一个接口实现类的对象,适合多个相同线程来处理同一份资源。
    • 增加程序的健壮性,实现解耦操作,代码可以被多个线程共享,代码和线程独立。

Thread 类的常用结构

构造器

  • public Thread() :分配一个新的线程对象。
  • public Thread(String name) :分配一个指定名字的新的线程对象。
  • public Thread(Runnable target) :指定创建线程的目标对象,它实现了 Runnable 接口中的 run 方法。
  • public Thread(Runnable target, String name):分配一个带有指定目标新的线程对象并指定名字。

常用方法

  • public void run() :此线程要执行的任务在此处定义代码。
  • public void start() :导致此线程开始执行; Java 虚拟机调用此线程的 run 方法。
  • public String getName() :获取当前线程名称。
  • public void setName(String name):设置该线程名称。
  • public static Thread currentThread() :返回对当前正在执行的线程对象的引用。在 Thread 子类中就是 this,通常用在主线程和 Runnable 实现类。
  • public static void sleep(long millis):使当前正在执行的线程以指定的毫秒数暂停(暂时停止执行)。
  • public static void yield():yield 只是让当前线程暂停一下,让系统的线程调度器重新调度一次,希望优先级与当前线程相同或更高的其他线程能够获得执行机会,但是这个不能保证,当某个线程调用了 yield 方法暂停之后,线程调度器可能会又将其调度出来重新执行。

  • public final boolean isAlive():测试线程是否处于活动状态。如果线程已经启动且尚未终止,则为活动状态。
  • void join() :等待该线程终止。
  • void join(long millis) :等待该线程终止的时间最长为 millis 毫秒。如果millis时间到,将不再等待。
  • void join(long millis, int nanos) :等待该线程终止的时间最长为 millis 毫秒 + nanos 纳秒。

每个线程都有一定的优先级,同优先级线程组成先进先出队列(先到先服务),使用分时调度策略。优先级高的线程采用抢占式策略,获得较多的执行机会。每个线程默认的优先级都与创建它的父线程具有相同的优先级。

  • Thread 类的三个优先级常量:
    • MIN _PRIORITY:1,最低优先级
    • NORM_PRIORITY:5,普通优先级,默认情况下main线程具有普通优先级。
    • MAX_PRIORITY:10,最高优先级
  • public final int getPriority() :返回线程优先级
  • public final void setPriority(int newPriority) :改变线程的优先级,范围在 [1,10] 之间。

Callable 接口

Callable 接口也可以用于创建线程。

  • 与使用 Runnable 相比, Callable 功能更强大些
    • 相比 run() 方法,可以有返回值
    • 方法可以抛出异常
    • 支持泛型的返回值(需要借助 FutureTask 类,获取返回结果)
  • Future 接口
    • 可以对具体 Runnable、Callable 任务的执行结果进行取消、查询是否完成、获取结果等。
    • FutureTask 是 Futrue 接口的唯一的实现类
    • FutureTask 同时实现了 Runnable、Future 接口。它既可以作为 Runnable 被线程执行,又可以作为 Future 得到 Callable 的返回值
  • 缺点:在获取分线程执行结果的时候,当前线程(或是主线程)受阻塞,效率较低。
java 复制代码
// 1.创建一个实现Callable的实现类
class NumThread implements Callable {
    // 2.实现call方法,将此线程需要执行的操作声明在call()中
    @Override
    public Object call() throws Exception {
        int sum = 0;
        for (int i = 1; i <= 100; i++) {
            if (i % 2 == 0) {
                System.out.println(i);
                sum += i;
            }
        }
        return sum;
    }
}

public class CallableTest {
    public static void main(String[] args) {
        // 3.创建Callable接口实现类的对象
        NumThread numThread = new NumThread();

        // 4.将此Callable接口实现类的对象作为传递到FutureTask构造器中,创建FutureTask的对象
        FutureTask futureTask = new FutureTask(numThread);
        // 5.将FutureTask的对象作为参数传递到Thread类的构造器中,创建Thread对象,并调用start()
        new Thread(futureTask).start();

				// 接收返回值
        try {
            // 6.获取Callable中call方法的返回值
            // get()返回值即为FutureTask构造器参数Callable实现类重写的call()的返回值。
            Object sum = futureTask.get();
            System.out.println("总和为:" + sum);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
}

介绍一下线程池

如果并发的线程数量很多,频繁创建线程就会大大降低系统的效率,因为频繁创建线程和销毁线程需要时间。

可以提前创建好多个线程,放入线程池中,使用时直接获取,使用完放回池中。可以避免频繁创建销毁、实现重复利用。

好处:

  • 提高响应速度(减少了创建新线程的时间)
  • 降低资源消耗(重复利用线程池中线程,不需要每次都创建)
  • 便于线程管理
    • corePoolSize:核心池的大小
    • maximumPoolSize:最大线程数
    • keepAliveTime:线程没有任务时最多保持多长时间后会终止
    • ......

线程池相关API

  • JDK 5.0 之前,我们必须手动自定义线程池。从 JDK5.0 开始,Java 内置线程池相关的 API。在 java.util.concurrent 包下提供了线程池相关API:ExecutorServiceExecutors
  • ExecutorService:真正的线程池接口。常见子类 ThreadPoolExecutor
    • void execute(Runnable command) :执行任务/命令,没有返回值,一般用来执行 Runnable
    • <T> Future<T> submit(Callable<T> task):执行任务,有返回值,一般用来执行 Callable
    • void shutdown() :关闭连接池
  • Executors:一个线程池的工厂类,通过此类的静态工厂方法可以创建多种类型的线程池对象。
    • Executors.newCachedThreadPool():创建一个可根据需要创建新线程的线程池
    • Executors.newFixedThreadPool(int nThreads); 创建一个可重用固定线程数的线程池
    • Executors.newSingleThreadExecutor() :创建一个只有一个线程的线程池
    • Executors.newScheduledThreadPool(int corePoolSize):创建一个线程池,它可安排在给定延迟后运行命令或者定期地执行。

代码举例:

java 复制代码
// JUC 中细说
class NumberThread implements Runnable{
    @Override
    public void run() {
        // ...
    }
}

class NumberThread1 implements Runnable{
    @Override
    public void run() {
        // ...
    }
}

class NumberThread2 implements Callable {
    @Override
    public Object call() throws Exception {
        // ...
    }
}

public class ThreadPoolTest {

    public static void main(String[] args) {
        // 1. 提供指定线程数量的线程池
        ExecutorService service = Executors.newFixedThreadPool(10);
        ThreadPoolExecutor service1 = (ThreadPoolExecutor) service;
        // 设置线程池的属性
        service1.setMaximumPoolSize(50); //设置线程池中线程数的上限

        // 2.执行指定的线程的操作。需要提供实现Runnable接口或Callable接口实现类的对象
        service.execute(new NumberThread());//适用于Runnable
        service.execute(new NumberThread1());//适用于Runnable

        try {
            Future future = service.submit(new NumberThread2()); // 适用于Callable
            System.out.println(future.get());
        } catch (Exception e) {
            e.printStackTrace();
        }
        // 3.关闭连接池
        service.shutdown();
    }
}

多线程的生命周期

🔥介绍一下多线程的生命周期

Java 语言使用 Thread 类及其子类的对象来表示线程。

在JDK 1.5之前,一个完整的线程的生命周期通常要经历五种状态,这是从操作系统层面来描述的:新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)、死亡(Dead)。

JDK 1.5 及之后的生命周期有 6 种状态,在 java.lang.Thread.State 的枚举类中这样定义:

java 复制代码
public enum State {
	NEW,
	RUNNABLE,
	BLOCKED,
	WAITING,
	TIMED_WAITING,
	TERMINATED;
}
  • NEW(新建):线程刚被创建,但是并未启动。还没调用 start 方法。
  • RUNNABLE(可运行) :线程被调用了 start() 等待运行的状态。这里没有单独区分 Ready 和 Running 状态。JVM 不能控制 Java 对象什么时候运行,只能由 OS 来调度 Java 对象且时间非常短暂,因此 JVM 无法区分 Java 对象的这两种状态。
  • Teminated(被终止):表明此线程已经结束生命周期,终止运行。

阻塞状态分为三种

  • BLOCKED(锁阻塞) :一个等待一个监视器锁的线程处于这一状态,只有获得锁对象的线程才能有执行机会。

    比如,线程 A 与线程 B 代码中使用同一锁,如果线程 A 获取到锁,线程 A 进入到 Runnable 状态,线程 B 就进入到 Blocked 锁阻塞状态。

  • TIMED_WAITING(计时等待) :一个正在限时等待另一个线程唤醒的线程处于这一状态。

    当前线程执行过程中遇到 Thread 类的 sleepjoin,Object 类的 wait,LockSupport类的 park 方法,并且在调用这些方法时设置了时间,那么当前线程会进入 TIMED_WAITING,直到时间到或被中断。

  • WAITING(无限等待) :一个正在无限期等待另一个线程唤醒的线程处于这一状态。

    当前线程执行过程中遇到下面的方法,并且在调用这些方法时没有指定时间,那么当前线程会进入 WAITING 状态,直到被唤醒。

    • 通过 Thread 类的 join 进入 WAITING 状态,只有调用 join 方法的线程对象结束才能让当前线程恢复
    • 通过 Object 类的 wait 进入 WAITING 状态的要有 Object 的 notify/notifyAll 唤醒
    • 通过 Condition 的 await 进入 WAITING 状态的要有 Condition 的 signal 方法唤醒
    • 通过 LockSupport 类的 park 方法进入 WAITING 状态的要有 LockSupport 类的 unpark 方法唤醒
java 复制代码
public class ThreadStateTest {
    public static void main(String[] args) throws InterruptedException {
        SubThread t = new SubThread();
        System.out.println(t.getName() + " 状态 " + t.getState());
        t.start();

        while (Thread.State.TERMINATED != t.getState()) {
            System.out.println(t.getName() + " 状态 " + t.getState());
            Thread.sleep(500);
        }
        System.out.println(t.getName() + " 状态 " + t.getState());
    }
}

class SubThread extends Thread {...}

线程安全

线程安全问题

当我们使用多个线程访问同一资源(同一个变量、文件、记录等)的时候,如果多个线程中对资源有读和写的操作,就容易出现线程安全问题。

为了解决多线程并发访问一个资源的安全性问题,Java 提供了同步机制(synchronized)来解决。在某个线程修改共享资源的时候,其他线程不能修改该资源,等待修改完毕同步之后,才能去抢夺 CPU 资源,这样做保证了数据的同步性,解决了线程不安全的现象。在任何时候,最多允许一个线程拥有同步锁,谁拿到锁才能进入代码块,其他的线程进入阻塞状态。

同步机制解决线程安全问题的原理

同步机制相当于给某段代码加"锁",任何线程想要执行这段代码,都要先获得"锁",我们称这个锁为同步锁。因为 Java 对象在堆中的数据分为分为对象头、实例变量、空白的填充。而对象头中包含:

  • Mark Word:记录了和当前对象有关的 GC、锁标记等信息;
  • 指向类的指针:每一个对象需要记录它是由哪个类创建出来的;
  • 数组长度(只有数组对象才有)。

某个线程获得了"同步锁"对象之后,"同步锁"对象就会记录这个线程的 ID,除非这个线程"释放"了锁对象,其他线程才能重新获得同步锁"对象。

同步代码块和同步方法

同步代码块:synchronized 关键字可以用于某个区块前面,表示只对这个区块的资源实行互斥访问。

java 复制代码
 synchronized(同步锁){
      需要同步操作的代码
 }

同步方法: synchronized 关键字直接修饰方法,表示同一时刻只有一个线程能进入这个方法。

java 复制代码
 public synchronized void method(){
     可能会产生线程安全问题的代码
 }

介绍一下同步锁机制

对于并发工作,你需要某种方式来防止两个任务访问相同的资源。 防止这种冲突的方法就是当资源被一个任务使用时,在其上加锁。第一个访问某项资源的任务必须锁定这项资源,使其他任务在其被解锁之前无法访问,而在其被解锁时,另一个任务就可以锁定并使用它。

synchronized 的锁是什么?

同步锁对象可以是任意类型,但是必须保证竞争"同一个共享资源"的多个线程必须使用同一个"同步锁对象"。

对于同步代码块来说,同步锁对象是由程序员手动指定的(很多时候也是指定为 this类名.class),但是对于同步方法来说,同步锁对象只能是默认的:

  • 静态方法:当前类的 Class 对象(类名.class
  • 非静态方法:this

介绍一下死锁

不同的线程分别占用对方需要的同步资源不放弃,都在等待对方放弃自己需要的同步资源,就形成了线程的死锁。

一旦出现死锁,整个程序既不会发生异常,也不会给出任何提示,只是所有线程处于阻塞状态,无法继续。

诱发死锁的原因有如下 4 种,同时触发会产生死锁:

  • 互斥条件:一个资源每次只能被一个进程使用
  • 占用且等待:一个进程因请求资源而阻塞时,对已获得的资源保持不放
  • 不可抢夺:进程已获得的资源,在末使用完之前,不能强行剥夺
  • 循环等待:死锁中所涉及的进程之间形成一个环路,每个进程都在等待下一个进程所持有的资源

解决死锁:

死锁一旦出现,基本很难人为干预,只能尽量规避。可以考虑打破上面的诱发条件。

  • 针对条件 1:互斥条件基本上无法被破坏。因为线程需要通过互斥解决安全问题。
  • 针对条件 2:可以考虑一次性申请所有所需的资源,这样就不存在等待的问题。
  • 针对条件 3:占用部分资源的线程在进一步申请其他资源时,如果申请不到,就主动释放掉已经占用的资源
  • 针对条件 4:可以将资源改为线性顺序。申请资源时,先申请序号较小的,这样避免循环等待问题。

介绍一下 Lock 接口

  • Lock 是 JDK 5.0 的新增功能,保证线程的安全。与采用 synchronized 相比,Lock 可提供多种锁方案。Lock 通过显式定义同步锁对象来实现同步。同步锁使用 Lock 对象充当。
  • java.util.concurrent.locks.Lock 接口是控制多个线程对共享资源进行访问的工具。锁提供了对共享资源的独占访问,每次只能有一个线程对 Lock 对象加锁,线程开始访问共享资源之前应先获得 Lock 对象。
  • 在实现线程安全的控制中,比较常用的是 ReentrantLock,可以显式加锁、释放锁。
    • ReentrantLock 类实现了 Lock 接口,它拥有与 synchronized 相同的并发性和内存语义,但是添加了类似锁投票、定时锁等候和可中断锁等候的一些特性。此外,它还提供了在激烈争用情况下更佳的性能。
  • Lock 锁也称同步锁,加锁与释放锁方法如下:
    • public void lock():加同步锁。
    • public void unlock():释放同步锁。
  • 代码结构
java 复制代码
 class A{
     //1. 创建Lock的实例,必须确保多个线程共享同一个Lock实例
     private final ReentrantLock lock = new ReenTrantLock();
     public void m(){
         //2. 调动lock(),实现需共享的代码的锁定
         lock.lock();
         try{
             //保证线程安全的代码;
         }
         finally{
             //3. 调用unlock(),释放共享代码的锁定
             lock.unlock();
         }
     }
 }

注意:如果同步代码有异常,要将 unlock() 写入 finally 语句块。

synchronized 与 Lock 的对比

  1. Lock 是显式锁(手动开启和关闭锁),synchronized 是隐式锁,出了作用域、遇到异常等自动解锁。
  2. Lock 只有代码块锁,synchronized 有代码块锁和方法锁。
  3. 使用 Lock 锁,JVM 将花费较少的时间来调度线程,性能更好。并且具有更好的扩展性(提供更多的子类),更体现面向对象。
  4. (了解)Lock 锁可以对读不加锁,对写加锁,synchronized 不可以。
  5. (了解)Lock 锁可以有多种获取锁的方式,可以从 sleep 的线程中抢到锁,synchronized 不可以。

建议处理线程安全问题时优先使用:Lock → 同步代码块 → 同步方法

线程的通信

为什么要处理线程间通信?

当我们需要多个线程来共同完成一件任务,并且我们希望他们有规律的执行,那么多线程之间需要一些通信机制,可以协调它们的工作,以此实现多线程共同操作一份数据。线程之间的通信即等待唤醒机制。

什么是等待唤醒机制?

这是多个线程间的一种协作机制。谈到线程我们经常想到的是线程间的竞争(race),比如去争夺锁,线程间也会有协作机制。

在一个线程满足某个条件时,就进入等待状态(wait() / wait(time)), 等待其他线程执行完他们的指定代码过后再将其唤醒(notify());或可以指定 wait 的时间,等时间到了自动唤醒;在有多个线程进行等待时,如果需要,可以使用 notifyAll() 来唤醒所有的等待线程。wait/notify 就是线程间的一种协作机制。

  1. wait:线程不再活动,不再参与调度,进入 wait set 中,因此不会浪费 CPU 资源,也不会去竞争锁了,这时的线程状态是 WAITING 或 TIMED_WAITING。它还要等着别的线程执行一个特别的动作,也即"通知(notify)"或者等待时间到,在这个对象上等待的线程从 wait set 中释放出来,重新进入到调度队列(ready queue)中。
  2. notify:唤醒被 wait() 的线程中优先级最高的那一个线程。如果相同,则随机唤醒一个。被唤醒的线程从当初被 wait 的位置继续执行。
  3. notifyAll:则释放所通知对象的 wait set 上的全部线程。

被通知的线程被唤醒后也不一定能立即恢复执行,因为它当初中断的地方是在同步块内,而此刻它已经不持有锁,所以它需要再次尝试去获取锁(很可能面临其它线程的竞争),成功后才能在当初调用 wait 方法之后的地方恢复执行。也就是说:

  • 如果能获取锁,线程就从 WAITING 状态变成 RUNNABLE 状态;
  • 否则,线程就从 WAITING 状态又变成 BLOCKED 状态。
java 复制代码
// 使用两个线程打印 1-100,线程1、线程2交替打印
class Communication implements Runnable {
    int i = 1;
    public void run() {
        while (true) {
            synchronized (this) {
                notify();
                if (i <= 100) {
                    System.out.println(Thread.currentThread().getName() + ":" + i++);
                } else
                    break;
                try {
                    wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

需注意的细节:

  1. wait 方法与 notify 方法必须要由同一个锁对象调用。因为对应的锁对象可以通过 notify 唤醒使用同一个锁对象调用的 wait 方法后的线程。
  2. wait 方法与 notify 方法是属于 Object 类的方法的。因为锁对象可以是任意对象,而任意对象的所属类都继承了 Object 类。
  3. wait 方法与 notify 方法必须要在同步代码块或同步函数中使用。因为必须要通过锁对象调用这 2 个方法。否则会报 java.lang.IllegalMonitorStateException 异常。

介绍一下生产者与消费者问题

等待唤醒机制可以解决经典的"生产者与消费者"的问题,它是一个多线程同步问题的经典案例。该问题描述了多个共享固定大小缓冲区的线程("生产者"和"消费者")在实际运行时会发生的问题。

生产者的主要作用是生成一定量的数据放到缓冲区中,然后重复此过程。与此同时,消费者也在缓冲区消耗这些数据。该问题的关键就是要保证生产者不会在缓冲区满时加入数据,消费者也不会在缓冲区中空时消耗数据。

举例:

生产者(Productor)将产品交给店员(Clerk),而消费者(Customer)从店员处取走产品,店员一次只能持有固定数量的产品(比如 20),如果生产者试图生产更多的产品,店员会叫生产者停一下,如果店中有空位放产品了再通知生产者继续生产;如果店中没有产品了,店员会告诉消费者等一下,如果店中有产品了再通知消费者来取走产品。

生产者与消费者问题中其实隐含了两个问题:

  • 线程安全问题:因为生产者与消费者共享数据缓冲区,产生安全问题。这个问题可以使用同步解决。

  • 线程的协调工作问题:

    要解决该问题,就必须让生产者线程在缓冲区满时等待(wait),暂停进入阻塞状态,等到下次消费者消耗了缓冲区中的数据的时候,通知(notify)正在等待的线程恢复到就绪状态,重新开始往缓冲区添加数据。同样,也可以让消费者线程在缓冲区空时进入等待(wait),暂停进入阻塞状态,等到生产者往缓冲区添加数据之后,再通知(notify)正在等待的线程恢复到就绪状态。通过这样的通信机制来解决此类问题。

代码实现:

java 复制代码
public class ProducerConsumer {
    public static void main(String[] args) {
        Clerk clerk = new Clerk();
        Producer producer = new Producer(clerk);
        Consumer consumer1 = new Consumer(clerk);
        Consumer consumer2 = new Consumer(clerk);

        producer.setName("生产者");
        consumer1.setName("消费者1");
        consumer2.setName("消费者2");

        producer.start();
        consumer1.start();
        consumer2.start();
    }
}

class Clerk {
    private int productNum = 0;
    private static final int MAX_PRODUCT_NUM = 20;
    private static final int MIN_PRODUCT_NUM = 0;

    public synchronized void addProduct() {
        if (productNum < MAX_PRODUCT_NUM) {
            productNum++;
            System.out.println(Thread.currentThread().getName() +
                               "\t生产了第" + productNum + "个产品");
            notify();
        } else {
            try {
                wait();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }

    public synchronized void minusProduct() {
        if (productNum > MIN_PRODUCT_NUM) {
            productNum--;
            System.out.println(Thread.currentThread().getName() +
                               "\t消费了第" + (productNum + 1) + "个产品");
            notify();
        } else {
            try {
                wait();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }
}

class Producer extends Thread {
    private Clerk clerk;

    public Producer(Clerk clerk) {
        this.clerk = clerk;
    }

    @Override
    public void run() {
        while (true) {
            try {
                Thread.sleep(40);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            clerk.addProduct();
        }
    }
}

class Consumer extends Thread {
    private Clerk clerk;

    public Consumer(Clerk clerk) {
        this.clerk = clerk;
    }

    @Override
    public void run() {
        while (true) {

            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            clerk.minusProduct();
        }
    }
}

🔥sleep() 和 wait() 有什么异同?

相同点: 一旦执行,都会使得当前线程结束执行状态,进入阻塞状态。

不同点:

  1. 定义方法所属的类不同:sleep()Thread 中定义,wait()Object 中定义。
  2. 使用范围不同:sleep() 可以在任何需要使用的位置被调用;wait() 必须使用在同步代码块或同步方法中。
  3. 是否释放锁的操作不同:sleep() 方法没有释放锁,而 wait() 方法释放了锁。
  4. 结束等待的方式不同:sleep() 指定时间一到就结束阻塞; wait() 可以指定时间也可以无限等待直到 notifynotifyAll
  5. 作用不同:wait() 通常被用于线程间通信,sleep()通常被用于暂停执行。

什么时候释放锁,什么时候不释放锁?

任何线程进入同步代码块、同步方法之前,必须先获得对同步监视器的锁定。

释放锁的操作

当前线程的同步代码块、同步方法中:

  1. 执行结束。
  2. 遇到 break、return 终止了该代码块、该方法的继续执行。
  3. 出现了未处理的 Error 或 Exception,导致当前线程异常结束。
  4. 执行了锁对象的 wait() 方法,当前线程被挂起,并释放锁。

不会释放锁的操作

线程执行同步代码块或同步方法时,程序调用 Thread.sleep()Thread.yield() 方法暂停当前线程的执行。

线程执行同步代码块时,其他线程调用了该线程的 suspend() 方法将该该线程挂起,该线程不会释放锁(同步监视器)。

应尽量避免使用 suspend()resume() 这样的过时操作来控制线程。

相关推荐
向宇it10 分钟前
【从零开始入门unity游戏开发之——C#篇24】C#面向对象继承——万物之父(object)、装箱和拆箱、sealed 密封类
java·开发语言·unity·c#·游戏引擎
小蜗牛慢慢爬行12 分钟前
Hibernate、JPA、Spring DATA JPA、Hibernate 代理和架构
java·架构·hibernate
Wyang_XXX1 小时前
CSS 选择器和优先级权重计算这么简单,你还没掌握?一篇文章让你轻松通关面试!(下)
面试
星河梦瑾1 小时前
SpringBoot相关漏洞学习资料
java·经验分享·spring boot·安全
黄名富1 小时前
Redis 附加功能(二)— 自动过期、流水线与事务及Lua脚本
java·数据库·redis·lua
love静思冥想1 小时前
JMeter 使用详解
java·jmeter
言、雲1 小时前
从tryLock()源码来出发,解析Redisson的重试机制和看门狗机制
java·开发语言·数据库
TT哇1 小时前
【数据结构练习题】链表与LinkedList
java·数据结构·链表
机器之心1 小时前
图学习新突破:一个统一框架连接空域和频域
人工智能·后端
Yvemil72 小时前
《开启微服务之旅:Spring Boot 从入门到实践》(三)
java