Java多线程(可能是全网最详细)

目录

1. 线程是什么?

1.1 先说操作系统

操作系统是一组计算机资源管理的软件的统称。
操作系统的基本功能:一是防止硬件被时空的应用程序滥用,二是向应用程序提供简单一致的机制来控制复杂而又通常大相径庭的低级硬件设备。

1.2 什么是程序?

程序是指令和数据的集合,其本身没有任何运行的含义,是一个静态的概念。

1.3 进程——正在运行的程序

进程是操作系统对一个正在运行的程序的一种抽象,可以把进程看做程序的一次运行过程;
在操作系统内部,进程又是操作系统进行资源分配的基本单位,各个进程互相之间是无法感受到对方存在,进程之间互相具备隔离性,进程A和进程B的内存独立不共享。

1.3.1 进程的运行方式

并行:微观上同一时刻,两个核心上的进程同时执行
并发:微观上同一时刻,一个核心上只能运行一个进程,但是它能对进程快速进行切换,看起来感觉像是这几个进程在同时进行。
除非显式声明,否则谈到并发就是指 并发+并行。

1.3.2 进程的"身份证"——进程控制块抽象(PCB)

1.3.2.1 PCB的内容

pid:进程的身份标识符(唯一数字)
内存指针:指向了自己的内存是哪些。
文件描述符表:硬盘上的文件等其他资源。
进度调度信息:进程的状态(就绪,运行,阻塞……),优先级,*上下文,*记账信息

*了解内容:
上下文:操作系统在进行进程切换的时候,就需要把进程执行的“中间状态”(CPU的各个寄存器的值)记录下来保存好,下次这个进程再上CPU上运行的时候,就可以恢复上次的状态好继续往下执行。保存上下文就是把这些CPU寄存器的值记录保存到内存中,恢复上下文就是把内存中这些寄存器值回复回去。
记账信息:操作系统,统计每个进程在CPU上占用的时间和执行的指令数目,
根据这个来决定下一阶段如何调度。

1.3.2.2 为什么会有PCB?

计算机内部要管理任何现实事物,都需要将其抽象成一组有关联的、互为一体的数据,PCB是Java语言中的类,每一个 PCB 对象,就代表着一个实实在在运行着的程序,也就是进程。

1.3.3 操作系统如何管理进程?

通过各种数据结构,例如线性表、搜索树等将 PCB 对象组织起来,方便进行增删查改的操作。
CPU分配:时间模式 —— 不同的进程在不同的时间段去使用 CPU 资源。
内存分配:空间模式 —— 不同进程使用内存中的不同区域,互相之间不会干扰。

1.3.4 进程间如何通信?

管道, 共享内存,文件,网络,信号量,信号
网络是一种相对特殊的 IPC 机制,它除了支持同主机两个进程间通信,还支持同一网络内部非同一主机上的进程间进行通信。

1.4 线程简介

1.4.1 线程

线程就是进程中的单个顺序控制流,也可以理解成是一条执行路径。

1.4.1.1 什么是单线程?

一个进程中包含一个顺序控制流(一条执行路径)
在这里插入图片描述

1.4.1.2 Java线程和操作系统线程之间的关系

线程是操作系统中的概念,操作系统内核实现了线程这样的机制, 并且对用户层提供了一些 API 供用户使用。
Java 标准库中 Thread 类可以视为是对 操作系统提供的 API 进行了进一步的抽象和封装。

1.4.1.3 什么是多线程?

一个进程中包含多个顺序控制流(多条执行路径)
在这里插入图片描述

1.4.1.4 多线程程序与普通线程程序的区别:

每个线程都是一个独立的执行流。
多个线程之间是 “并发” 执行的。

1.4.2 为什么会有线程?

单核 CPU 的发展遇到了瓶颈. 要想提高算力, 就需要多核 CPU. 而并发编程能更充分利用多核 CPU 资源.。
有些任务场景需要 “等待 IO”, 为了让等待 IO 的时间能够去做一些其他的工作, 也需要用到并发编程。
虽然多进程也能实现 并发编程, 但是线程比进程更轻量.创建线程比创建进程更快,销毁线程比销毁进程更快.,调度线程比调度进程更快.
最后, 线程虽然比进程轻量, 又有"线程池"(ThreadPool) 和 “协程”(Coroutine)。

1.4.3 JVM与如何调度多线程?

在一个进程中,如果开辟了多个线程,线程的运行由调度器安排调度,调度器是与操作系统紧密相关的,先后顺序是不能人为的干预的。

线程会带来额外的开销,如cpu调度时间,并发控制开销。

在这里插入图片描述
在java语言中:
线程A和线程B,堆内存和方法区内存共享。但是栈内存独立,一个线程一个栈。
假设启动10个线程,会有10个栈空间,每个栈和每个栈之间,互不干扰,各自执行各自的,这就是多线程并发。

对于单核的CPU来说,不能够做到真正的多线程并发,但是可以做到给人一种“多线程并发”的感觉。对于单核的CPU来说,在某一个时间点上实际上只能处理一件事情,但是由于CPU的处理速度极快,多个线程之间频繁切换执行,跟人来的感觉是多个事情同时在做。

在程序运行时,即使没有自己创建线程,后台也会有多个线程,如主线程,gc线程。
main()称之为主线程,为系统的入口,用于执行整个程序。

对同一份资源操作时,会存在资源抢夺的问题,需要加入并发控制。每个线程在自己的工作内存交互,内存控制不当会造成数据不一致.

1.4.4 Java多线程是如何实现数据共享的?

JVM 把内存分成了:方法区, 堆区, 栈区, 程序计数器
其中堆区这个内存区域是多个线程之间共享的,只要把某个数据放到堆内存中, 就可以让多个线程都能访问到。

1.5 进程和线程的区别与联系?

1.5.1 进程与线程的区别

进程是系统分配资源的最小独立单位,线程是系统调度执行的最小单位。
进程是包含线程的:个进程可以包含多个线程,每个进程至少有一个线程存在,即主线程。只有第一个线程启动的时候开销比较大,后续线程就省事了。进程分为:单线进程,单线程进程,多个单线程进程,多个多线程进程。
同一个进程里的多个线程之间共用了进程的同一份资源(主要是指内存和文件描述符表),可以不通过内核进行直接通信。
进程和进程之间不共享内存空间,同一个进程的线程之间共享同一个内存空间,进程有自己的内存地址空间,线程只独享指令流执行的必要资源,如寄存器和栈

1.5.1 线程的优点

创建一个新线程的代价要比创建一个新进程小得多。
线程的创建、切换及终止效率更高。
与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。
线程占用的资源要比进程少很多。
线程能充分利用多处理器的可并行数量。
在等待慢速I/O操作结束的同时,程序的线程可执行其他的计算任务。
如果一个进程有多个线程,每个线程是独立在CPU上运行的。计算密集型应用,为了能在多处理器系统上运行,可以将计算分解到多个线程中实现。
I/O密集型应用,为了提高性能,线程可以同时等待不同的I/O操作,将I/O操作重叠。

2. Java多线程的创建

2.1 先认识一下Thread类

2.1.1 什么是Thread类?

Thread 类是 JVM 用来管理线程的一个类,用Thread 类的对象就是来描述一个线程执行流,JVM 会将这些 Thread 对象组织起来,用于线程调度,线程管理。

2.1.2 Thread类的构造方法

Thread():创建线程对象。
Thread(Runnable target):使用 Runnable 对象或实现了Runnable接口的对象创建线程对象。
Thread(String name):创建线程对象,并命名。
Thread(Runnable target, String name):使用 Runnable 对象或实现了Runnable接口的对象创建线程对象,并命名。
Thread(ThreadGroup group,Runnable target):线程可以被用来分组管理,分好的组即为线程组。

2.1.3 Thread类的属性

ID:是线程的唯一标识,不同线程不会重复;getld()
名称:名称是各种调试工具用到;getName()
状态:状态表示线程当前所处的一个情况;getState()
优先级:优先级高的线程理论上来说更容易被调度到;getPriority()
是否后台线程:JVM会在一个进程的所有非后台线程结束后,才会结束运行;isDaemon()
是否存活:是否存活,可简单的理解为 run 方法是否运行结束了;isAlive()
是否被中断:isinterrupted()

2.1.3.1 线程的优先级

在Java中每一个线程有一个优先级,默认情况下一个线程会继承构造它那个线程的优先级,Java提供一个线程调度器来监控程序中启动后进入就绪状态的所有线程,线程调度器按照优先级决定应该调度哪个线程来执行。优先级越高的线程获得CPU执行的机会越大。优先级越低的线程获得CPU执行的机会越小。

2.1.3.1.1 优先级的设定

线程的优先级用数字表示,范围从1~10
Thread.MIN_PRIORITY= 1;
Thread.MAX_PRIORITY = 10;
Thread.NORM_PRIORITY = 5;
使用以下方式改变或获取优先级
getPriority() . setPriority(int xxx)

2.2 创建多线程

2.2.1 继承Thread类

自定义线程类继承Thread类。
重写run()方法,编写线程执行体。
创建线程对象,调用start()方法启动线程。

案例一:

public class TestThread {
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        // 开启myThread线程
        myThread.start();
        // main线程
        while (true){
            System.out.println("Demo类的main运行方法");
        }
    }
}
class MyThread extends Thread{
    public void run(){
        while (true){
            System.out.println("MyThread类的run方法运行");
        }
    }
}

案例二:

public class Test {
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        // 开启子线程
        myThread.start();  
        // main线程
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName()+"---"+i);
        }
    }
}
class MyThread extends Thread{
    @Override
    public void run() {
        Thread.currentThread().setName("子线程");
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName()+"---"+i);
        }
    }
}
2.2.2 实现Runnable接口

定义MyRunnable类实现Runnable接口
实现run()方法,编写线程执行体
创建线程对象,调用start()方法启动线程

案例一:

class MyThread implements Runnable{
    public void run(){
        while (true){
            System.out.println("MyThread类的run方法");
        }
    }
}
public class Demo01 {
    public static void main(String[] args) {
        //创建一个实现了Runnable接口的对象
        MyThread myThread = new MyThread();
        //将该对象传给Thread类的构造函数
        Thread thread = new Thread(myThread);
        //thread对象调用start方法,在这里不会调用自身的run方法,它会调用myThread对象的run方法
        thread.start();
        while (true){
            System.out.println("Demo类的main方法");
        }
    }
}

案例二:

public class MyThread implements Runnable{
    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName()+"---"+i);
        }
    }
}
class Test{
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        Thread thread1 = new Thread(myThread);
        thread1.start();
        Thread thread2 = new Thread(myThread);
        thread2.start();
    }
}
2.2.2.1 实现Runnable接口与继承Thread类对比

继承Thread类:
子类继承Thread类具有多线程能力
启动线程:子类对象.start()
不建议使用:避免OOP单继承局限性
实现Runnable接口:
实现接口Runnable具有多线程能力
启动线程:传入目标对象+Thread对象.start()
推荐使用:避免单继承局限性,灵活方便,方便同一个对象被多个线程使用

2.2.3 使用匿名内部类创建Thread子类对象

案例:

// 使用匿名类创建 Thread 子类对象
Thread t1 = new Thread() {
    @Override
    public void run() { 
        System.out.println("使用匿名类创建 Thread 子类对象"); 
    }
};
2.2.4 使用匿名内部类创建Runable子类对象

案例:

// 使用匿名类创建 Runnable 子类对象
Thread t2 = new Thread(new Runnable() {
    @Override
    public void run() { 
        System.out.println("使用匿名类创建 Runnable 子类对象"); 
    }
});
2.2.5 lambda表达式创建Runable子类对象

案例:

// 使用 lambda 表达式创建 Runnable 子类对象
Thread t3 = new Thread(() ->
 System.out.println("使用匿名类创建 Thread 子类对象")); 


Thread t4 = new Thread(() -> {
    System.out.println("使用匿名类创建 Thread 子类对象");
});
2.2.6 使用callable接口

3. 线程的状态

NEW:新建
RUNNABLE:可运行
BLOCKED:阻塞
WAITING:等待
TIMED_WAITING:计时等待
TERMINATED:终止
在这里插入图片描述

3.1 NEW

当用new操作符创建一个新线程时,这个线程还没开始运行,此时就是新建状态(NEW)。仅仅由Java虚报机为其分配了内存,没有表现出任何线程的动态特征。当一个线程处于新建状态时,程序还没开始运行线程中的代码。

3.2 RUNNABLE

一旦调用start方法,线程就处于RUNNABLE状态,一个可运行状态的线程可能正在运行也可能没运行,由操作系统为线程提供具体的CPU运行时间片,Java规范没有将“正在运行“作为一个单独状态,一个正在运行的线程仍然处于RUNNABLE状态。

一旦一个线程开始运行,它不一定始终保持运行,有时需要暂停让其他线程有机会运行。具体调度细节依赖于操作系统,抢占式调度的方式给每一个可运行线程一个时间片来运行,时间片用完后,操作系统会剥夺该线程的运行权(线程占有的CPU资源)给其他另一个线程,并且会考虑线程的优先级。

3.3 BLOCKED,WAITING,TIMED_WAITING

当线程处于阻塞或等待状态时暂时是不活动的,不执行任何代码并消耗最少的资源。
当线程调用了一个阻塞式的IO方法时,该线程就会进人阻塞状态,如果想进人就绪状态就必须要等到这个阻塞的IO方法返回。
当线程调用了某个对象的wait ( )方法时,也会使线程进人阻塞状态,如果想进入就绪状态就需要使用notify ( )方法唤醒该线程。
当线程调用了Thread的sleep (long millis)方法时,也会使线程进人阻塞状态,在这种情况下,只需等到线程睡眠的时间到了以后,线程就会自动进人就绪状态。
当在一个线程中调用了另 一个线程的join ()方法时,会使当前线程进人阻塞状态,在这种情况下,需要等到新加人的线程运行结束后才会结束阻塞状态,进入就绪状态。
当线程试图获取某个对象的同步锁时,如果该锁被其他线程所持有,则当前线程会进人阻塞状态。如果想从阻塞状态进人就绪状态必须得获取到其他线程所持有的锁。
当一个线程尝试获取一个内部的对象锁(不是java.util.concurrent库中的Lock),同时这个锁当前被其他线程占有,这个线程就会被阻塞。当所有其他线程都释放了这个锁并且线程调度器允许该线程持有这个锁时,这个线程就变成非阻塞状态。

当线程等待另一个线程通知调度器出现某个条件时就会进入等待状态,调用Object.wait或Thread.join方法或等待java.util.concurrent库中的Lock或Condition时就会出现这种情况,实际上阻塞状态与等待状态没有太大区别。
通过调用Thread.sleep、计时版Object.wait、Thread.join、Lock.tryLock以及Condition.await方法,会让线程进入TIME_WAITING(计时等待),这一状态将保持到超时期或者接收到适当的通知。

当一个线程阻塞或等待或终止时,可以调度另一个线程运行,当一个线程被重新激活时,线程调度器会检查它是否具有比当前运行线程更高的优先级,如果是这样,调度器会剥夺某个当前正在运行线程的运行权,选择运行一个新线程。

3.4 终止线程

由于run方法正常结束退出,线程自然终止,
因为一个没有捕获的异常终止了run方法,使线程意外终止。
调用stop方法杀死一个线程。
不推荐使用JDK提供的stop()、destroy()方法。【已废弃】
推荐线程自己停止下来
建议使用一个标志位进行终止变量当flag=false,则终止线程运行。

3.5 获取线程状态的方法

getState()

4. 线程控制

4.1 sleep()方法

public class DemoSleep {
    public static void main(String[] args) {
        // 创建线程
        MyThread1 t01 = new MyThread1("线程1");
        MyThread1 t02 = new MyThread1("线程2");
        //开启线程
        t01.start();
        t02.start();
    }
}
class MyThread1 extends Thread{
    public MyThread1() {
    }
 
    public MyThread1(String name) {
        super(name);
    }
 
    @Override
    // 重点:run()当中的异常不能throws,只能try catch
    // 因为run()方法在父类中没有抛出任何异常,子类不能比父类抛出更多的异常。
    public void run() {
        for (int i = 1; i < 50; i++) {
            System.out.println(this.getName() + i );
 
            try {
                Thread.sleep(500);//让当前正在执行的线程睡眠指定毫秒数
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

4.2 终断睡眠 interrupt()和stop()

让线程暂时睡眠指定时长,线程进入阻塞状态。
睡眠时间过后线程会再进入可运行状态。
sleep (时间)指定当前线程阻塞的毫秒数。
sleep存在异常InterruptedException。
sleep时间达到后线程进入就绪状态。
sleep可以模拟网络延时,倒计时等。
每一个对象都有一个锁,sleep不会释放锁。

public class DemoInterrupt {
    public static void main(String[] args) {
        Thread t = new Thread(new MyRunnable2());
        t.setName("t");
        // 启动t线程
        t.start();
        // main线程 休眠 5 秒
        try {
            Thread.sleep(1000 * 5);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // main线程终断t线程的睡眠 (这种终断睡眠的方式依靠了java的异常处理机制。)
        t.interrupt();
        // t.stop(); //强行终止线程
        //缺点:容易损坏数据  线程没有保存的数据容易丢失
    }
}
class MyRunnable2 implements Runnable {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + "---> begin");
        try {
            // t线程 休眠1年
            Thread.sleep(1000 * 60 * 60 * 24 * 365);
        } catch (InterruptedException e) {
            // e.printStackTrace();
        }
        // 如果t线程不被终断 1年之后才会执行这里
        System.out.println(Thread.currentThread().getName() + "---> end");
    }
}

4.3 合理终止线程——做一个boolean类型的标记

public class DemoSleep02 {
    public static void main(String[] args) {
        MyRunable4 r = new MyRunable4();
        Thread t = new Thread(r);
        t.setName("t");
        t.start();
 
        // main线程 休眠5秒
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 在这里写下终止线程的操作
        // ......
        // 把标记修改为false,就结束了。
        r.run = false;
    }
}
class MyRunable4 implements Runnable {
 
    // 打标记
    boolean run = true;
 
    @Override
    public void run() {
        for (int i = 0; i < 10; i++){
            if(run){
                System.out.println(Thread.currentThread().getName() + "--->" + i);
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }else{
                // return就结束了
                // 可以进行 save....
                //终止当前线程
                return;
            }
        }
    }
}

4.4 yield()——让出CPU执行权

暂停当前正在执行的线程对象,并执行其他线程
yield()方法不是阻塞方法。让当前线程让位,让给其它线程使用。
yield()方法的执行会让当前线程从正在运行状态回到RUNNABLE状态。
在回到就绪之后,有可能还会再次抢到CPU执行权。

public class DemoYield {
    public static void main(String[] args) {
        //创建线程
        MyThread5 t01 = new MyThread5("线程01");
        MyThread5 t02 = new MyThread5("线程02");
        MyThread5 t03 = new MyThread5("线程03");
        t01.start();
        t02.start();
        t03.start();
    }
}
class MyThread5 extends Thread{
    public MyThread5() {
    }
    public MyThread5(String name) {
        super(name);
    }
 
    @Override
    public void run() {
        for (int i = 0; i < 50; i++) {
            if(30 == i) Thread.yield();//当循i环到30时,让线程让步
                //1、回到抢占队列中,又争夺到了执行权
                //2、回到抢占队列中,没有争夺到执行权
            
            System.out.println(this.getName() + ":" + i);
        }
    }
}

4.5 join()——线程插队

也叫线程的强制执行。
使当前线程暂停执行,等待其他线程结束后再继续执行本线程
可以控制两个线程的结束顺序。
public final void join()
public final void join(long mills)
public final void join(long mills,int nanos)
millis:以毫秒为单位的等待时长
nanos:要等待的附加纳秒时长
需处理InterruptedException异常

public class ThreadDemo3  {
    public static void main(String[] args) throws InterruptedException{
        // 创建 t线程
        Thread t = new Thread(()->{
            for (int i = 0; i < 3; i++) {
                System.out.println("t线程 正在执行");
                try {
                    Thread.sleep(1000*5);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        // 启动 t线程
        t.start(); // 由main线程执行

        System.out.println("main线程正在执行,t.join()之前"); // 由main线程执行

        //此处的join就是让当前的main线程来等待t线程执行结束(等待t的run执行完)
        try {
            // t线程 插入进来
            t.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("main线程正在执行,t.join()之后"); // 由main线程执行
    }
}
// 执行完start之后,t线程 和main线程就并发执行,
// main继续往下执行,t也会继续往下执行。
// t.join调用之后main发生阻塞,
// 一直阻塞到t线程执行结束,main线程才会从join中恢复过来继续往下执行,t 线程肯定比main先结束。
// 如果是执行t.join的时候,run已经结束了,main就不会阻塞,就会立即返回。

在这里插入图片描述

5. 线程安全

如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。

5.1 线程安全问题案例

一个代码究竟是线程安全不安全都得具体问题具体分析,难以一概而论。

  1. 多个执行流访问同一个共享资源的时候:
    线程模型天然就是资源共享的,抢占式调度,多线程争抢同一个资源(同一个变量)非常容易触发。
    进程模型天然是资源隔离的,不容易触发,进行进程间通信的时候,多个进程访问同一个资源,可能会出现问题。
  2. 多个线程同时修改同一个变量的时候
  3. 修改操作不是原子性的时候:
    一条 java 语句不一定是原子的,也不一定只是一条指令,如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。
    这点也和线程的抢占式调度密切相关. 如果线程不是 “抢占” 的, 就算没有原子性, 也问题不大。
  4. 有内存可见性的时候:
    内存可见性 一个线程对共享变量值的修改,不能够及时地被其他线程看到。
  5. 发生指令重排序的时候:
    为了加快程序的执行效率,编译器在保持代码逻辑不变的情况下调整了代码的执行顺序。

5.2 线程不安全的原因

5.2.1 修改共享数据

案例:

class Counter {
    public int count = 0;
    public void add() {
        count++;
    }
}
public class ThreadDemo5 {
    public static void main(String[] args) {
        Counter counter = new Counter();
        
        //两个线程分别对counter进行操作,调用5w次add方法
        Thread t1 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        Thread t2 = new Thread(()->{
            for (int i = 0; i < 50000; i++) {
                counter.add();
            }
        });
        t1.start();
        t2.start();

        //等待两个线程结束
        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        //打印最终的count值
        System.out.println("count = "+ counter.count);
    }
}

运行结果:在这里插入图片描述在这里插入图片描述在这里插入图片描述
三次运行的结果都不一样

原因:上面的线程不安全的代码中,涉及到多个线程针对 counter.count 变量进行修改。
此时这个 counter.count 是一个多个线程都能访问到的 “共享数据”。在这里插入图片描述

5.2.2 程序的原子性问题

一条 java 语句不一定是原子的,也不一定只是一条指令,如果一个线程正在对一个变量操作,中途其他线程插入进来了,如果这个操作被打断了,结果就可能是错误的。这点也和线程的抢占式调度密切相关,如果线程不是 “抢占” 的, 就算没有原子性, 也问题不大。
在这里插入图片描述
示例中的add方法的++操作分成三步完成:
1.先把内存中的值读取到CPU的寄存器中 (load)
2.把CPU寄存器里的数值进行+1运算(add)
3.把得到的结果写到内存中(save)

5.2.3 抢占式调度

两个线程并发执行count++,
此时就相当于两组load add save 同时执行,
此时不同的线程调度顺序就可能产生结果上的差异。

5.2.4 指令重排序导致逻辑不等价

为了提高执行效率,JVM、CPU指令集会对其进行优化,进行指令重排序。
一段代码是这样的:

  1. 去前台取下 U 盘
  2. 去教室写 10 分钟作业
  3. 去前台取下快递
    如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如按 1->3->2的方式执行,也是没问题,可以少跑一次前台。编译器对于指令重排序的前提是 “保持逻辑不发生变化”。这一点在单线程环境下比较容易判断,但是在多线程环境下就没那么容易了。
    多线程的代码执行复杂程度更高,编译器很难在编译阶段对代码的执行效果进行预测,因此激进的重排序很容易导致优化后的逻辑和之前不等价。

5.2.5 可见性问题

可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到。
一个线程针对一个变量进行读取操作,同时另一个线程针对这个变量进行修改,此时读到的值不一定是修改后的值。这个读线程没有感知到变量的变化,归根结底是编译器jvm在多线程环境下优化时产生了误判。

5.2.4.1 JMM模型

为了屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。Java虚拟机规范中定义了Java内存模型 (JMM)。
在这里插入图片描述
为了提高效率,线程之间的共享变量存在 主内存 (Main Memory)。每一个线程都有自己的 “工作内存” (Working Memory)。
所谓的 “主内存” 是真正硬件角度的 “内存”,所谓的 “工作内存”, 则是指 CPU 的寄存器和高速缓存。CPU 访问自身寄存器的速度以及高速缓存的速度,远远超过访问内存的速度(快了 3 - 4 个数量级, 也就是几千倍, 上万倍),效率大大提高了。

当线程要读取一个共享变量的时候,会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据。当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存。在这里插入图片描述
由于每个线程有自己的工作内存,这些工作内存中的内容相当于同一个共享变量的 “副本”。此时修改 线程1 的工作内存中的值, 线程2 的工作内存不一定会及时变化。
在这里插入图片描述
一旦 线程1 修改了 a 的值, 此时主内存不一定能及时同步。对应的 线程2 的工作内存的 a 的值也不一定能及时同步。这个时候代码中就容易出现可见性问题。

案例:

import java.util.Scanner;
class MyCounter {
   public int flag = 0;
}
public class ThreadDemo8 {
   public static void main(String[] args) {
       MyCounter myCounter= new MyCounter();

       // t1要循环快速重复读取
       Thread t1 = new Thread(()->{
           while (myCounter.flag==0) {
           }
           System.out.println("t1循环结束");
      });

    // t2 进行修改
     Thread t2 = new Thread(()->{
         Scanner scanner = new Scanner(System.in);
         System.out.println("请输入一个整数:");
         myCounter.flag = scanner.nextInt();
     });

     t1.start();
     t2.start();
   }
}

在这里插入图片描述
// 当输入 1 的时候,t1 这个线程并没有结束循环。

  while(myCounter.flag == 0) {

  }

这里的操作分为两步:

  1. load,把内存中的flag的值,读取到寄存器里。
  2. cmp,把寄存器的值和0进行比较,根据比较的结果,决定下一步往哪个地方执行。
    这两步操作是个循环,速度极快。在 t2 真正修改之前,load得到的结果都是一样的。
    CPU对寄存器的操作比对内存的操作快很多,所以load操作和cmp操作相比,速度慢非常多。由于load执行的速度太慢,加上反复load到的结果都一样,JVM就自动优化,进行指令重排序,不再真正的重复load,只读取一次就好了,当 t2 对 flag 变量进行修改, 此时 t1 感知不到 flag 的变化,于是就造成了内存可见性问题。

5.3 变量对线程安全的影响

实例变量/成员变量:在堆中。
静态变量:在方法区。
局部变量:在栈中。
局部变量永远都不会存在线程安全问题。 因为局部变量不共享(一个线程一个栈)。
实例变量在堆中,堆只有1个,静态变量在方法区中,方法区只有1个,堆和方法区都是多线程共享的,所以可能存在线程安全问题。
常量:不会有线程安全问题。

5.4 编程模型

5.4.1 异步编程模型

多线程并发,线程t1和线程t2,各自执行各自的,t1不管t2,t2不管t1, 谁也不需要等谁,效率较高。

5.4.2 同步编程模型

线程排队执行, 线程t1和线程t2,在线程t1执行的时候,必须等待t2线程执行结束,或者说在t2线程执行的时候,必须等待t1线程执行结束, 两个线程之间发生了等待关系,这就是同步编程模型,效率较低。

5.4.2.1 线程同步机制(锁机制synchronized)
5.4.2.1.1 同步代码块

将处理共享资源的代码放置在一个代码块中,使用synchronized关键字来修饰,被称作同步代码块。


 synchronized (lock) {
   // 操作共享资源代码块
   
 }  
 // synchronized括号后的数据必须是多线程共享的数据,才能达到多线程排队。

lock 是一个锁对象,称之为同步监视器,它是同步代码块的关键。
当线程执行同步代码块时,首先会检查锁对象的标志位,默认情况下标志位为1。
如果此时标志位为1,线程会执行同步代码块,同时将锁对象的标志位置为0。
当一个新的线程执行到这段同步代码块时,由于锁对象的标志位为0,新线程会发生阻塞,
等待当前线程执行完同步代码块后,锁对象的标志位被置为1,新线程才能进人同步代码块执行其中的代码。循环往复,直到共享资源被处理完为止。

案例:

//        以下代码的执行原理
//        1、假设t1和t2线程并发,开始执行以下代码的时候,肯定有一个先一个后。
//        2、假设t1先执行了,遇到了synchronized,这个时候自动找“后面共享对象”的对象锁,
//        找到之后,并占有这把锁,然后执行同步代码块中的程序,在程序执行过程中一直都是
//        占有这把锁的。直到同步代码块代码结束,这把锁才会释放。
//        3、假设t1已经占有这把锁,此时t2也遇到synchronized关键字,也会去占有后面
//        共享对象的这把锁,结果这把锁被t1占有,t2只能在同步代码块外面等待t1的结束,
//        直到t1把同步代码块执行结束了,t1会归还这把锁,此时t2终于等到这把锁,然后
//        t2占有这把锁之后,进入同步代码块执行程序。
//
//        这样就达到了线程排队执行。
//        这里需要注意的是:这个共享对象一定要选好了。这个共享对象一定是需要排队
//        的这些线程的对象所共享的。
        synchronized (this){
            double before = this.getBalance();
            double after = before - money;
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            this.setBalance(after);
        }


// 可以显式/手动指定锁对象。
// 进入代码块就加锁,出了代码块就解锁。

// 锁当前对象
public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
          
        }
    }
}


// 锁类对象
  public class SynchronizedDemo {
  public void method() {
      synchronized (SynchronizedDemo.class) {

      }
  }
}


//  一个加锁一个不加锁:和没加锁一样
public void add1 () {
      // 修饰代码块,
          synchronized(this) {
                count++;
          }
      }
      public void add2() {
      count++;
      }
      
}
5.4.2.1.2 同步方法

修饰符 synchronized 返回值类型 方法名(形参列表){

    // 方法体
}

被synchronized修饰的方法在某一时刻只允许一个线程访问,访问该方法的其他线程都会发生阻塞,直到当前线程访问完毕后,其他线程才有机会执行方法。
synchronized出现在实例方法上,一定锁的是this(此方法),不能是其他的对象了, 所以这种方式不灵活。另外还有一个缺点:synchronized出现在实例方法上, 表示整个方法体都需要同步,可能会无故扩大同步的 范围,导致程序的执行效率降低,所以这种方式不常用。
案例

    public synchronized void withdraw(double money){
        double before = this.getBalance(); // 10000
        // 取款之后的余额
        double after = before - money;
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 更新余额
        this.setBalance(after);
5.4.2.1.3 同步静态方法

修饰符 synchronized static 返回值类型 方法名(形参列表){

     // 方法体
}

 public class SynchronizedDemo {
       public synchronized static void method() {
       }
   }

静态方法中不能使用this,进入方法就加锁,离开方法就解锁,锁对象就是类对象,类锁永远只有1把。

5.4.2.1.4 synchronized的作用
5.4.2.1.4.1 互斥——保证原子性

synchronized的底层使用操作系统的mutex lock实现,synchronized用的锁是存在Java对象头里的。在这里插入图片描述
加了synchronized之后能够保证原子性,进入 synchronized 修饰的代码块, 相当于加锁,退出 synchronized 修饰的代码块, 相当于解锁。
针对每一把锁, 操作系统内部都维护了一个等待队列。当这个锁被某个线程占有的时候, 其他线程尝试进行加锁,就加不上了, 就会阻塞等待,一直等到之前的线程解锁之后,由操作系统唤醒等待队列中一个新的线程, 再来获取到这个锁。这也就是操作系统线程调度的一部分工作。
系统中的锁具有“不可剥夺”特性,一旦一个线程得到锁除非它主动释放,否则无法强占。
如果两个线程同时尝试对同一个对象加锁,就会出现锁竞争,此时一个能获锁取成功,另一个只能阻塞(BLOCKED),一直阻塞到刚才线程释放锁当前线程才能加锁。在这里插入图片描述

假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁,
此时 B 和 C 都在阻塞队列中排队等待,但是当 A 释放锁之后, 虽然 B 比 C 先来的,但是 B 不一定就能获取到锁,而是和 C 重新竞争, 并不遵守先来后到的规则,由操作系统根据线程的优先级来进行调度。

两个线程分别尝试获取两把不同的锁, 不会产生竞争。如果两个线程针对不同队形加锁,此时不会发生锁竞争,这两个线程都能获取到各自的锁,不会有阻塞等待。在这里插入图片描述

5.4.2.1.4.2 刷新内存——保证内存可见性

synchronized 的工作过程:

  1. 获得互斥锁
  2. 从主内存拷贝变量的最新副本到工作的内存
  3. 执行代码
  4. 将更改后的共享变量的值刷新到主内存
  5. 释放互斥锁

所以 synchronized 也能保证内存可见性。

案例:

// 对上面的代码进行调整: 

static class Counter {
    public int flag = 0;
}

public static void main(String[] args) {
    Counter counter = new Counter();
    Thread t1 = new Thread(() -> {
        while (true) {
            synchronized (counter) {
               if (counter.flag != 0) {
                    break;
                }
            }
            // do nothing
        }
        System.out.println("循环结束!");
    });

    Thread t2 = new Thread(() -> {
        Scanner scanner = new Scanner(System.in);
        System.out.println("输入一个整数:");
        counter.flag = scanner.nextInt();
    });

    t1.start();
    t2.start();
}
5.4.2.1.4.3 可重入

synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题。
在可重入锁的内部,包含了 “线程持有者” 和 “计数器” 两个信息,如果某个线程加锁的时候, 发现锁已经被人占用,但是恰好占用的正是自己, 那么仍然可以继续获取到锁, 并让计数器自增,解锁的时候计数器递减为 0 的时候, 才真正释放锁,才能被别的线程获取到。

案例:


static class Counter {
    public int count = 0;

    synchronized void increase() {
        count++;
    }

    synchronized void increase2() {
        increase();
    }
}


// increase 和 increase2 两个方法都加了 synchronized, 
// 此处的 synchronized 都是针对 this 当前对象加锁的. 


// 在调用 increase2 的时候, 先加了一次锁, 
// 执行到 increase 的时候, 又加了一次锁. (上个锁还没释放, 相当于连续加两次锁)
// 这个代码是完全没问题的. 因为 synchronized 是可重入锁。

5.4.2.1.4 锁原理
5.4.2.1.4.1 常见的锁策略

乐观锁 vs 悲观锁:

悲观锁:悲观锁认为多个线程访问同一个共享变量冲突的概率较大, 会在每次访问共享变量之前都去真正加锁.
乐观锁:
乐观锁认为多个线程访问同一个共享变量冲突的概率不大. 并不会真的加锁, 而是直接尝试访问数据. 在访问的同时识别当前的数据是否出现访问冲突.,如果发现并发冲突了,则让返回用户错误的信息,让用户决定如何去做。

例如:
同学 A 认为 “老师是比较忙的, 我来问问题, 老师不一定有空解答”. 因此同学 A 会先给老师发消息: “老师你忙嘛? 我下午两点能来找你问个问题嘛?” (相当于加锁操作) 得到肯定的答复之后, 才会真的来问问题. 如果得到了否定的答复, 那就等一段时间, 下次再来和老师确定时间. 这个是悲观锁.
同学 B 认为 “老师是比较闲的, 我来问问题, 老师大概率是有空解答的”. 因此同学 B 直接就来找老师.(没加锁, 直接访问资源) 如果老师确实比较闲, 那么直接问题就解决了. 如果老师这会确实很忙, 那么同学 B 也不会打扰老师, 就下次再来(虽然没加锁, 但是能识别出数据访问冲突). 这个是乐观锁.

乐观锁的实现可以引入一个 “版本号” 来解决,检测出数据是否发生访问冲突

设当前余额为 100. 引入一个版本号 version, 初始值为 1. 并且我们规定 “提交版本必须大于记录当前版本才能执行更新余额”。

  1. 线程 A 此时准备将其读出( version=1, balance=100 ),线程 B 也读入此信息( version=1, balance=100 )在这里插入图片描述
  1. 线程 A 操作的过程中并从其帐户余额中扣除 50( 100-50 ),线程 B 从其帐户余额中扣除 20 ( 100-20 )。在这里插入图片描述
  1. 线程 A 完成修改工作,将数据版本号加1( version=2 ),连同帐户扣除后余额( balance=50 ),写回到内存中。在这里插入图片描述
  1. 线程 B 完成了操作,也将版本号加1( version=2 )试图向内存中提交数据( balance=80 ),但此时比对版本发现,操作员 B 提交的数据版本号为 2 ,数据库记录的当前版本也为 2 ,不满足 “提交版本必须大于记录当前版本才能执行更新“ 的乐观锁策略。就认为这次操作失败. 在这里插入图片描述

读写锁:

一个线程对于数据的访问, 主要存在两种操作: 读数据 和 写数据.

  1. 两个线程都只是读一个数据, 此时并没有线程安全问题. 直接并发的读取即可.
  2. 两个线程都要写一个数据, 有线程安全问题.
  3. 一个线程读另外一个线程写, 也有线程安全问题.

Java 标准库提供了 ReentrantReadWriteLock 类, 实现了读写锁.

  1. ReentrantReadWriteLock.ReadLock 类表示一个读锁. 这个对象提供了 lock / unlock 方法进行加锁解锁。
  2. ReentrantReadWriteLock.WriteLock 类表示一个写锁. 这个对象也提供了 lock / unlock 方法进行加锁解锁

读写锁就是把读操作和写操作区分对待.

读加锁和读加锁之间, 不互斥.
写加锁和写加锁之间, 互斥.
读加锁和写加锁之间, 互斥.

只要是涉及到 “互斥”, 就会产生线程的挂起等待. 一旦线程挂起, 再次被唤醒就不知道隔了多
久了. 因此尽可能减少 “互斥” 的机会, 就是提高效率的重要途径,读写锁特别适合于 “频繁读, 不频繁写” 的场景中。

重量级锁 vs 轻量级锁:

锁的核心特性 “原子性”, 这样的机制追根溯源是 CPU 这样的硬件设备提供的:
CPU 提供了 “原子操作指令”.
操作系统基于 CPU 的原子指令, 实现了 mutex 互斥锁.
JVM 基于操作系统提供的互斥锁, 实现了 synchronized 和 ReentrantLock 等关键字和类. 在这里插入图片描述

用户态 vs 内核态:
想象去银行办业务.
在窗口外, 自己做, 这是用户态. 用户态的时间成本是比较可控的.
在窗口内, 工作人员做, 这是内核态. 内核态的时间成本是不太可控的.
如果办业务的时候反复和工作人员沟通, 还需要重新排队, 这时效率是很低的.

重量级锁:
加锁机制重度依赖了 OS 提供了 mutex,大量的内核态用户态切换很容易引发线程的调度。这两个操作, 成本比较高. 一旦涉及到用户态和内核态的切换, 就意味着 “沧海桑田”.

轻量级锁:
加锁机制尽可能不使用 mutex, 而是尽量在用户态代码完成. 实在搞不定了, 再使用 mutex。少量的内核态用户态切换. 不太容易引发线程调度。

自旋锁(Spin Lock) vs 挂起等待锁:

自旋锁:
如果获取锁失败, 立即再尝试获取锁, 无限循环, 直到获取到锁为止. 第一次获取锁失败, 第二次的尝试会在极短的时间内到来,一旦锁被其他线程释放, 就能第一时间获取到锁。

自旋锁伪代码:

   while (抢锁(lock) == 失败) {}

理解自旋锁 vs 挂起等待锁:
想象一下, 去追求一个女神. 当男生向女神表白后, 女神说: 你是个好人, 但是我有男朋友了~~
挂起等待锁: 陷入沉沦不能自拔… 过了很久很久之后, 突然女神发来消息, “咱俩要不试试?” (注意, 这个很长的时间间隔里, 女神可能已经换了好几个男票了).
自旋锁: 死皮赖脸坚韧不拔. 仍然每天持续的和女神说早安晚安. 一旦女神和上一任分手, 那么就能立刻抓住机会上位.

自旋锁是一种典型的 轻量级锁 的实现方式:
优点: 没有放弃 CPU, 不涉及线程阻塞和调度, 一旦锁被释放, 就能第一时间获取到锁.
缺点: 如果锁被其他线程持有的时间比较久, 那么就会持续的消耗 CPU 资源. (而挂起等待的时候是不消耗 CPU 的).

线程在抢锁失败后进入阻塞状态,放弃 CPU,需要过很久才能再次被调度. 但实际上, 大部分情况下,虽然当前抢锁失败,但过不了很久,锁就会被释放。没必要就放弃 CPU. 这个时候就可以使用自旋锁来处理这样的问题。

公平锁 vs 非公平锁:
假设三个线程 A, B, C. A 先尝试获取锁, 获取成功. 然后 B 再尝试获取锁, 获取失败, 阻塞等待; 然后C 也尝试获取锁, C 也获取失败, 也阻塞等待.
当线程 A 释放锁的时候, 会发生啥呢?

公平锁: 遵守 “先来后到”. B 比 C 先来的. 当 A 释放锁的之后, B 就能先于 C 获取到锁.
非公平锁: 不遵守 “先来后到”. B 和 C 都有可能获取到锁.

操作系统内部的线程调度就可以视为是随机的. 如果不做任何额外的限制, 锁就是非公平锁. 如果要想实现公平锁, 就需要依赖额外的数据结构, 来记录线程们的先后顺序。
公平锁和非公平锁没有好坏之分, 关键还是看适用场景.

可重入锁 vs 不可重入锁:

可重入锁:“可以重新进入的锁”,即允许同一个线程多次获取同一把锁。
Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括synchronized关键字锁都是可重入的, Linux 系统提供的 mutex 是不可重入锁。

不可重入锁:“把自己锁死”
一个线程没有释放锁, 然后又尝试再次加锁.,第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无法进行解锁操作. 这时候就会 死锁。

  // 第一次加锁, 加锁成功
  lock();
  // 第二次加锁, 锁已经被占用, 阻塞等待. 
  lock();

在这里插入图片描述

5.4.2.1.4.2 CAS

什么是 CAS?:

CAS: 全称Compare and swap,字面意思:”比较并交换“。
广义的理解 CAS是一种思想,一种在并发环境下保证多线程并发对同一个公共变量做修改时线程安全的。
站住锁的角度理解: CAS是一种乐观锁,即他每次都认为不存在冲突所以“不加锁”去完成某个操作,如果失败了就去重试,和悲观锁对比就很轻量(悲观锁是认为每次都要加锁保证自己独占才安全)

一个 CAS 涉及到以下操作:

假设内存中的原数据V,旧的预期值A,需要修改的新值B。

  1. 比较 A 与 V 是否相等。(比较)
  2. 如果比较相等,将 B 写入 V。(交换)
  3. 返回操作是否成功。

当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但是并不会阻塞其他线程,其他线程只会收到操作失败的信号。
CAS 可以视为是一种乐观锁. (或者可以理解成 CAS 是乐观锁的一种实现方式)。

CAS 伪代码 :

// 真实的 CAS 是一个原子的硬件指令完成的. 这个伪代码只是辅助理解CAS 的工作流程。
//CAS 是直接读写内存的, 而不是操作寄存器. 
//CAS 的读内存, 比较, 写内存操作是一条硬件指令, 是原子的

boolean CAS(address, expectValue, swapValue) {
if (&address == expectedValue) {
    &address = swapValue;
       return true;
  }
   return false;
}

两种典型的不是 “原子性” 的代码

  1. check and set (if 判定然后设定值)
  2. read and update (i++)

CAS 是怎么实现的

硬件予以支持,软件层面才能做到。
针对不同的操作系统,JVM 用到了不同的 CAS 实现原理:

  1. java 的 CAS 利用的的是 unsafe 这个类提供的 CAS 操作
  2. unsafe 的 CAS 依赖了的是 jvm 针对不同的操作系统实现的 Atomic::cmpxchg
  3. Atomic::cmpxchg 的实现使用了汇编的 CAS 操作,并使用 cpu 硬件提供的 lock 机制保证其原子性。

CAS 有哪些应用

  1. 实现原子类
  1. 实现自旋锁

CAS 的 ABA 问题

原子类

原子类内部用的是 CAS 实现,所以性能要比加锁实现 i++ 高很多。
AtomicBoolean
AtomicInteger
AtomicIntegerArray
AtomicLong
AtomicReference
AtomicStampedReference

5.4.2.1.4.3 synchronized原理

基本特点:

  1. 开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁
  2. 开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁
  3. 实现轻量级锁的时候大概率用到的自旋锁策略
  4. 是一种非公平锁
  5. 是一种可重入锁
  6. 不是读写锁

加锁工作过程:

无锁,偏向锁,轻量级锁,重量级锁,根据情况依次升级。
1.无锁
2.偏向锁:第一个尝试加锁的线程, 优先进入偏向锁状态。

偏向锁不是真的 “加锁”, 只是给对象头中做一个 “偏向锁的标记”, 记录这个锁属于哪个线程.如果后续没有其他线程来竞争该锁, 那么就不用进行其他同步操作了(避免了加锁解锁的开销) 如果后续有其他线程来竞争该锁(刚才已经在锁对象中记录了当前锁属于哪个线程了, 很容易识别 当前申请锁的线程是不是之前记录的线程), 那就取消原来的偏向锁状态, 进入一般的轻量级锁状态.

  1. 轻量级锁

随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态(自适应的自旋锁). 此处的轻量级锁就是通过 CAS 来实现:1.通过 CAS 检查并更新一块内存 (比如 null => 该线程引用) 。2. 如果更新成功, 则认为加锁成功。3.如果更新失败, 则认为锁被占用, 继续自旋式的等待(并不放弃 CPU)。

  1. 重量级锁

如果竞争进一步激烈, 自旋不能快速获取到锁状态, 就会膨胀为重量级锁 此处的重量级锁就是指用到内核提供的 mutex 。

  • 执行加锁操作, 先进入内核态.
  • 在内核态判定当前锁是否已经被占用
  • 如果该锁没有占用, 则加锁成功, 并切换回用户态.
  • 如果该锁被占用, 则加锁失败. 此时线程进入锁的等待队列, 挂起. 等待被操作系统唤醒.
  • 经历了一系列的沧海桑田, 这个锁被其他线程释放了, 操作系统也想起了这个挂起的线程, 于是唤醒 这个线程, 尝试重新获取锁.
5.4.2.1.4.4 锁优化:
  • 锁消除:
    有些应用程序中用到了synchronized,但其实没有在多线程环境下,编译器+JVM 判断锁是否可消除,如果可以,就直接消除.。

例如StringBuffer:

StringBuffer sb = new StringBuffer(); sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");

此时每个 append 的调用都会涉及加锁和解锁. 但如果只是在单线程中执行这个代码, 那么这些加 锁解锁操作是没有必要的, 白白浪费了一些资源开销.

  • 锁粗化:
    一段逻辑中如果出现多次加锁解锁, 编译器 + JVM 会自动进行锁的粗化。实际开发过程中, 使用细粒度锁, 是期望释放锁的时候其他线程能使用锁. 但是实际上可能并没有其他线程来抢占这个锁. 这种情况 JVM 就会自动把锁粗化, 避免频繁申请释放锁.在这里插入图片描述
    举个栗子理解锁粗化:

滑稽老哥当了领导, 给下属交代工作任务:
方式一:
打电话, 交代任务1, 挂电话.
打电话, 交代任务2, 挂电话.
打电话, 交代任务3, 挂电话.
方式二:
打电话, 交代任务1, 任务2, 任务3, 挂电话.
显然, 方式二是更高效的方案.

5.4.2.1.4.5 JUC(java.util.concurrent) 的常见类
  • ReentrantLock

可重入互斥锁. 和 synchronized 定位类似, 都是用来实现互斥效果, 保证线程安全.

  • ReentrantLock 的用法:
  1. lock(): 加锁, 如果获取不到锁就死等.
  2. rylock(超时时间): 加锁, 如果获取不到锁, 等待一定的时间之后就放弃加锁.
  3. unlock(): 解锁
ReentrantLock lock = new ReentrantLock(); -----------------------------------------

lock.lock();
try {
   // working
} finally { 
   lock.unlock()
}

  • ReentrantLock 和 synchronized 的区别:
  1. synchronized 是一个关键字, 是 JVM 内部实现的(大概率是基于 C++ 实现). ReentrantLock 是标准库的一个类, 在 JVM 外实现的(基于 Java 实现).
  2. synchronized 使用时不需要手动释放锁. ReentrantLock 使用时需要手动释放. 使用起来更灵活, 但是也容易遗漏 unlock.
  3. synchronized 在申请锁失败时, 会死等. ReentrantLock 可以通过 trylock 的方式等待一段时间就 放弃.
  4. synchronized 是非公平锁, ReentrantLock 默认是非公平锁. 可以通过构造方法传入一个 true 开启 公平锁模式.
// ReentrantLock 的构造方法
public ReentrantLock(boolean fair) {
 sync = fair ? new FairSync() : new NonfairSync();
}
  1. 更强大的唤醒机制. synchronized 是通过 Object 的 wait / notify 实现等待-唤醒. 每次唤醒的是一 个随机等待的线程. ReentrantLock 搭配 Condition 类实现等待-唤醒, 可以更精确控制唤醒某个指 定的线程.
  • 如何选择使用哪个锁?
  1. 锁竞争不激烈的时候, 使用 synchronized, 效率更高, 自动释放更方便.
  2. 锁竞争激烈的时候, 使用 ReentrantLock, 搭配 trylock 更灵活控制加锁的行为, 而不是死等.
  3. 如果需要使用公平锁, 使用 ReentrantLock.
5.4.2.2 死锁
  1. 当两个或多个线程互相请求对方拥有的资源时,就可能发生死锁。
  2. 一个线程,一把锁,连续加锁两次,如果锁是不可重入锁就会死锁。
  3. 多个线程多把锁

死锁的四个必要条件:

  1. 互斥使用:
    线程1拿到了锁,线程2就得等着。
  2. 不可抢占:
    线程1拿到锁之后,必须是线程1主动释放。
  3. 请求和保持:
    线程1拿到锁A之后,再次尝试获取锁B,A这把锁还是保持的(不会因为获取锁B就把A给释放了)。
  4. 循环等待:
    线程1尝试获取锁A和锁B,线程2尝试获取锁B和锁A。线程1在获取B的时候等待线程2释放B,线程2在获取A的时候等待线程1释放A。

案例一:

线程1先锁定资源1,然后尝试锁定资源2。同时,线程2先锁定资源2,然后尝试锁定资源1。由于两个线程互相持有对方所需的资源,它们会相互等待对方释放资源,从而导致死锁。


public class DeadlockExample {
    public static void main(String[] args) {
        final Object resource1 = new Object();
        final Object resource2 = new Object();
        
        // 线程1尝试获取resource1,然后resource2
        Thread thread1 = new Thread(() -> {
            synchronized (resource1) {
                System.out.println("Thread 1: locked resource 1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (resource2) {
                    System.out.println("Thread 1: locked resource 2");
                }
            }
        });
        
        // 线程2尝试获取resource2,然后resource1
        Thread thread2 = new Thread(() -> {
            synchronized (resource2) {
                System.out.println("Thread 2: locked resource 2");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (resource1) {
                    System.out.println("Thread 2: locked resource 1");
                }
            }
        });
        
        // 启动两个线程
        thread1.start();
        thread2.start();
    }
}

案例二:

不可重入锁造成死锁: 一个线程没有释放锁, 然后又尝试再次加锁。

// 第一次加锁, 加锁成功
lock();
// 第二次加锁, 锁已经被占用, 阻塞等待. 
lock();

在这里插入图片描述
按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待。
直到第一次的锁被释放, 才能获取到第二个锁。但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无法进行解锁操作,这时候就会死锁。

methodA 和 methodB 分别尝试先获取 lock1 和 lock2,然后再尝试获取另一个锁。当两个线程同时执行这两个方法时,它们会相互等待对方释放锁,从而导致死锁。
为了避免这种情况,应该使用可重入锁(Reentrant Lock),例如 java.util.concurrent.locks.ReentrantLock,它允许多次获得同一把锁,并且不会造成死锁。

public class DeadLockExample {
    private final Object lock1 = new Object();
    private final Object lock2 = new Object();

    public void methodA() {
        synchronized (lock1) {
            System.out.println(Thread.currentThread().getName() + " 获取了 lock1");

            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            synchronized (lock2) {
                System.out.println(Thread.currentThread().getName() + " 尝试获取 lock2");
                // 这里会发生死锁,因为当前线程已经持有 lock1,但无法再次获取 lock2
            }
        }
    }

    public void methodB() {
        synchronized (lock2) {
            System.out.println(Thread.currentThread().getName() + " 获取了 lock2");

            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            synchronized (lock1) {
                System.out.println(Thread.currentThread().getName() + " 尝试获取 lock1");
                // 这里也会发生死锁,因为当前线程已经持有 lock2,但无法再次获取 lock1
            }
        }
    }

    public static void main(String[] args) {
        DeadLockExample example = new DeadLockExample();

        Thread threadA = new Thread(() -> example.methodA());
        Thread threadB = new Thread(() -> example.methodB());

        threadA.start();
        threadB.start();
    }
}

案例三:

两个线程thread1和thread2,它们都尝试以不同的顺序获取两把锁lock1和lock2。如果这两个线程几乎同时启动,它们可能会相互等待对方释放锁,从而导致死锁。

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class MultiLocksDeadlock {

    // 定义两把锁
    private static final Lock lock1 = new ReentrantLock();
    private static final Lock lock2 = new ReentrantLock();

    public static void main(String[] args) {
        Thread thread1 = new Thread(new Task("task1", lock1, lock2));
        Thread thread2 = new Thread(new Task("task2", lock2, lock1));

        thread1.start();
        thread2.start();
    }

    static class Task implements Runnable {
        private String name;
        private Lock firstLock;
        private Lock secondLock;

        public Task(String name, Lock firstLock, Lock secondLock) {
            this.name = name;
            this.firstLock = firstLock;
            this.secondLock = secondLock;
        }

        @Override
        public void run() {
            try {
                // 线程尝试以不同的顺序获取两把锁
                firstLock.lock();
                System.out.println(name + " got the first lock");

                Thread.sleep(1000); // 模拟一些工作

                secondLock.lock();
                System.out.println(name + " got the second lock");

                // 做一些工作...

            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                // 释放锁
                secondLock.unlock();
                firstLock.unlock();
            }
        }
    }
}

5.4.2.2.1 如何避免死锁?

给锁编号,然后指定一个固定的顺序(比如从小到大)来加锁,任意线程加多把锁的时候,都让线程遵守上述程序,此时循环等待自然破除。

案例:

5.5 多线程同步

5.5.1 应用场景

由于同一进程的多个线程共享同一块存储空间,多个线程访问同一个对象并且某些线程还想修改这个对象会带来安全问题,为了保证数据在方法中被访问时的正确性就需要进行线程同步。
线程同步其实就是一种等待机制,多个需要同时访问此对象的线程进入这个对象的等待池形成队列,等待前面线程使用完毕,下一个线程再使用。

5.5.2 多线程同步实现原理

通过private关键字来保证数据对象只能被方法访问,针对方法运用锁机制synchronized,当一个线程获得对象的排它锁,独占资源﹐其他线程必须等待,使用后释放锁即可。

5.5.3 锁机制synchronized存在的问题

一个线程持有锁会导致其他所有需要此锁的线程挂起;
在多线程竞争下,加锁,释放锁会导致比较多的上下文切换和调度延时,引起性能问题;
如果一个优先级高的线程等待一个优先级低的线程释放锁会导致优先级倒置,引起性能问题。

5.6 如何保证线程安全

思路:

  1. 使用没有共享资源的模型
  2. 适用共享资源只读,不写的模型

2.1 不需要写共享资源的模型
2.2 使用不可变对象

  1. 直面线程安全(重点)

3.1 保证原子性
3.2 保证顺序性
3.3 保证可见性

5.6.1 尽量使用局部变量代替“实例变量和静态变量”。

5.6.2 创建多个对象

如果必须是实例变量,那么可以考虑创建多个对象,这样实例变量的内存就不共享了。(一个线程对应1个对象,100个线程对应100个对象,对象不共享,就没有数据安全问题了。)

5.6.3 使用synchronized线程同步机制。

5.6.4 借助Lock加锁

不一定要在同一个方法中进行解锁,如果在当前的方法体内部没有满足解锁需求时,可以将lock引用传递到下一个方法中,当满足解锁需求时进行解锁操作,方法比较灵活。

  private Lock lock = new ReentrantLock();//定义Lock类型的锁
  public  void withdraw(double money){
    // t1和t2并发这个方法。。。。(t1和t2是两个栈。两个栈操作堆中同一个对象。)
    // 取款之前的余额
     lock.lock();//上锁
     double before = this.getBalance(); // 10000
     // 取款之后的余额
     double after = before - money;
     // 在这里模拟一下网络延迟,100%会出现问题
     try {
         Thread.sleep(1000);
     } catch (InterruptedException e) {
         e.printStackTrace();
     }

      // 更新余额
       // 思考:t1执行到这里了,但还没有来得及执行这行代码,t2线程进来withdraw方法>了。此时一定出问题。
       this.setBalance(after);
       lock.unlock();//解锁
   }

5.6.5 借助volatile关键字

5.6.5.1 内存可见性问题
import java.util.Scanner;
class MyCounter {
    public int flag = 0;
}
public class ThreadDemo8 {
    public static void main(String[] args) {
        MyCounter myCounter= new MyCounter();

        // t1要循环快速重复读取
        Thread t1 = new Thread(()->{
            while (myCounter.flag==0) {
            }
            System.out.println("t1循环结束");
        });

        // t2 进行修改
        Thread t2 = new Thread(()->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个整数:");
            myCounter.flag = scanner.nextInt();
        });

        t1.start();
        t2.start();
    }
}

// 执行效果
// 当用户输入非0值时, t1 线程循环不会结束. (这显然是一个 bug)

在这里插入图片描述

从JMM(Java Memory Modle)角度重新表述这个内存可见性问题:
Java程序里,每个线程分别有自己的工作内存(t1和t2的工作内存不是同一个东西)。
t1线程进行读取的时候,只是读取了工作内存的值。
t2线程进行修改的时候,先修改工作内存的值,然后再把工作内存的内容同步到主内存中。

由于编译器优化,导致t1没有重新从主内存同步数据到工作内存,当 t2 对 flag 变量进行修改, 此时 t1 感知不到 flag 的变化。
读到的结果就是修改之前的结果。(工作内存是指CPU寄存器和CPU的cache)在这里插入图片描述

// 当输入 1 的时候,t1 这个线程并没有结束循环。

while(myCounter.flag == 0) {

}

这里的操作分为两步:

  1. load,把内存中的flag的值,读取到寄存器里。
  2. cmp,把寄存器的值和0进行比较,根据比较的结果,决定下一步往哪个地方执行。

这两步操作是个循环,速度极快。
在 t2 真正修改之前,load得到的结果都是一样的。
CPU对寄存器的操作比对内存的操作快很多,所以load操作和cmp操作相比,速度慢非常多。

由于load执行的速度太慢,而且反复load到的结果都一样,
JVM就自动优化,进行指令重排序:不在真正的重复load,只读取一次就好了,当 t2 对 flag 变量进行修改, 此时 t1 感知不到 flag 的变化,所以就造成了内存可见性问题。

5.6.5.2 volatile保证内存可见性原理

volatile的两个功能:解决内存可见性问题,禁止指令重排序。
代码在写入 volatile 修饰的变量的时候,先改变线程工作内存中volatile变量副本的值,再将改变后的副本的值从工作内存刷新到主内存。
代码在读取 volatile 修饰的变量的时候,先从主内存中读取volatile变量的最新值到线程的工作内存中,再从工作内存中读取volatile变量的副本。
给变量加上volatile关键字,告诉编译器这个变量时“易变的”,每一次都一定要重新读取这个变量的内容,不要进行激进的指令重排序优化了。
加上 volatile , 强制读写内存. 速度是慢了, 但是数据变的更准确了。

案例:

// 如果给 flag 加上 volatile
static class Counter {
    public volatile int flag = 0;
}

// 执行效果
// 当用户输入非0值时, t1 线程循环能够立即结束. 
5.6.5.3 volatile 不保证原子性

案例:


// 给 increase 方法去掉 synchronized 
// 给 count 加上 volatile 关键字.

static class Counter {
    volatile public int count = 0;

    void increase() {
        count++;
    }
}

public static void main(String[] args) throws InterruptedException {
    final Counter counter = new Counter();

    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            counter.increase();
        }
    });
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 50000; i++) {
            counter.increase();
        }
    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();

    System.out.println(counter.count);
}

尽管volatile关键字提供了变量的可见性保证,但它并不提供原子性保证。count++操作不是原子性的,因为它包含了三个独立的步骤:读取count的值,将其加一,然后将新值写回count。如果有两个线程同时执行这个操作,它们可能会读取相同的count值,各自加一,然后写回相同的结果,导致计数不正确。

5.7 Java标准库中的线程安全类

5.7.1 有线程安全问题需要手动加锁的类:

ArrayList
LinkedList
HashMap
TreeMap
HashSet
TreeSet
StringBuilder

5.7.1.1 多线程环境使用 ArrayList
  1. 使用同步机制 (synchronized 或者 ReentrantLock
  2. Collections.synchronizedList(new ArrayList):
    synchronizedList 是标准库提供的一个基于 synchronized 进行线程同步的 List.
    synchronizedList 的关键操作上都带有 synchronized
  3. 使用 CopyOnWriteArrayList

CopyOnWrite容器即写时复制的容器。
当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,
复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。
这样做的好处是我们可以对CopyOnWrite容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。
所以CopyOnWrite容器也是一种读写分离的思想,读和写不同的容器。
优点:在读多写少的场景下, 性能很高, 不需要加锁竞争.
缺点:占用内存较多,新写的数据不能被第一时间读取到.

5.7.1.2 多线程环境使用队列
  1. ArrayBlockingQueue,基于数组实现的阻塞队列
  2. LinkedBlockingQueue,基于链表实现的阻塞队列
  3. PriorityBlockingQueue,基于堆实现的带优先级的阻塞队列
  4. TransferQueue,最多只包含一个元素的阻塞队列
5.7.1.3 多线程环境使用哈希表

HashMap 本身不是线程安全的。在多线程环境下使用哈希表可以使用:Hashtable,ConcurrentHashMap。

5.7.1.3.1 Hashtable

只是简单的把关键方法加上了 synchronized 关键字.
在这里插入图片描述
在这里插入图片描述
这相当于直接针对 Hashtable 对象本身加锁.
如果多线程访问同一个 Hashtable 就会直接造成锁冲突.
size 属性也是通过 synchronized 来控制同步, 也是比较慢的.
一旦触发扩容, 就由该线程完成整个扩容过程. 这个过程会涉及到大量的元素拷贝, 效率会非常低.
在这里插入图片描述

5.7.1.3.2 ConcurrentHashMap

相比于 Hashtable 做出了一系列的改进和优化

读操作没有加锁(但是使用了 volatile 保证从内存读取结果), 只对写操作进行加锁. 加锁的方式仍然是是用 synchronized, 但是不是锁整个对象, 而是 “锁桶” (用每个链表的头结点作为锁对象), 大大降低了锁冲突的概率.
充分利用 CAS 特性. 比如 size 属性通过 CAS 来更新. 避免出现重量级锁的情况.
优化了扩容方式: 化整为零
发现需要扩容的线程, 只需要创建一个新的数组, 同时只搬几个元素过去.
扩容期间, 新老数组同时存在.
后续每个来操作 ConcurrentHashMap 的线程, 都会参与搬家的过程. 每个操作负责搬运一小
部分元素.
搬完最后一个元素再把老数组删掉.
这个期间, 插入只往新数组加.
这个期间, 查找需要同时查新数组和老数组在这里插入图片描述

Java1.7锁分段技术:

读操作没有加锁(但是使用了 volatile 保证从内存读取结果), 只对写操作进行加锁. 加锁的方式仍然是是用 synchronized, 但是不是锁整个对象,把若干个哈希桶分成一个"段" (Segment), 针对每个段分别加锁,目的也是为了降低锁竞争的概率. 当两个线程访问的数据恰好在同一个段上的时候, 才触发锁竞争.

jdk1.8优化:

取消了分段锁, 直接给每个哈希桶(每个链表)分配了一个锁(就是以每个链表的头结点对象作为锁对象).
将原来 数组 + 链表 的实现方式改进成 数组 + 链表 / 红黑树 的方式. 当链表较长的时候(大于等于8 个元素)就转换成红黑树。

Hashtable和HashMap、ConcurrentHashMap 之间的区别?

HashMap: 线程不安全. key 允许为 null
Hashtable: 线程安全. 使用 synchronized 锁 Hashtable 对象, 效率较低. key 不允许为 null.
ConcurrentHashMap: 线程安全. 使用 synchronized 锁每个链表头结点, 锁冲突概率低, 充分利用
CAS 机制. 优化了扩容方式. key 不允许为 null

5.7.2 内置了synchronized加锁的类:

Vector
HashTable
ConcurrentHushMap
StringBuffer 核心方法都带有 synchronized 。
在这里插入图片描述

5.7.3 string没有加锁但是因为不能修改仍是线程安全的。

5.8 守护线程

Java语言中线程分为两大类:用户线程,守护线程(后台线程),其中具有代表性的就是:垃圾回收线程(守护线程)。
一般守护线程是一个死循环,所有的用户线程只要结束,守护线程自动结束。(主线程main方法是一个用户线程。)
设置方法:. setDeamon(boolean)。

//测试守护线程
//国家守护你
public class TestDaemon {
    public static void main(String[] args) {
        China china = new China();
        You you = new You();
        Thread thread = new Thread(china);//让国家编程守护线程
        thread.setDaemon(true);//setDaemon返回值是Boolean 默认是false表示用户线程
        thread.start();//守护线程启动
        new Thread(you).start();//用户线程启动
    }
}

class China implements Runnable{  //守护线程
    @Override
    public void run() {
        while(true){//国家永远存在
            System.out.println("国家保护着你!");
        }
    }
}
class You implements Runnable{ //用户线程
 
    @Override
    public void run() {
        for (int i = 0; i < 30000; i++) {
            System.out.println("你开心的活着");
        }
        System.out.println("*******goodbye!*******");
    }
}

5.9 定时器

定时器的作用:间隔特定的时间,执行特定的程序。
在java的类库中已经写好了一个定时器:java.util.Timer,可以直接拿来用
案例

import java.util.Timer;
import java.util.TimerTask;

public class DemoTimer {
    public static void main(String[] args) {
        Timer timer = new Timer();//创建Timer定时器类的对象
        //匿名内部类
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("我被执行了!~");
                System.gc();//告诉JVM运行完毕,可以把我回收
            }
        },5000);
    }
}

线程与定时器之间互不抢占CPU时间片

import java.util.Timer;
import java.util.TimerTask;
 
/**
 * @author Mr.乐
 * @Description 线程与定时器的执行轨迹不同
 */
public class DemoTimer {
    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
               for (int i = 0; i < 20; i++) {
                 System.out.println(Thread.currentThread().getName() + "<--->" + i);
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
               }
            }
        }).start();
 
        //定时器实现
        new Timer().schedule(new TimerTask() {
            @Override
            public void run() {
                for (int i = 0; i < 10; i++) {
                   System.out.println(Thread.currentThread().getName() + "---" + i);
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                System.gc();//将编程垃圾的定时器进行回收
            }
        },5000);
    }
}

6. 协调多个线程

线程之间是抢占式执行的, 因此线程之间执行的先后顺序难以预知,实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序。

场景:
生产者将产品交给店员,而消费者从店员处取走产品,店员一次只能持有固定数量的产品,如果生产者生产了过多的产品,店员会叫生产者等一下,如果店中有空位放产品了再通知生产者继续生产;如果店中没有产品了,店员会告诉消费者等一下,如果店中有产品了再通知消费者来取走产品。

在这个应用场景中,我们可以得出线程间通信,指的一个线程完成了自己的任务时,要通知另外一个线程去完成另外一个任务。要完成线程间通信,就需要控制多个线程按照一定的顺序轮流执行。

6.1 三个Object类的方法

在Object类中提供了wait (), notify (), notifyAll ()方法用于解决线程间的通信问题,由于Java中所有类都是Object类的子类或间接子类,因此任何类的实例对象都可以直接使用这些方法。这三个方法的调用者不是线程对象,应该是同步锁对象,否则Java 虚拟机就会抛ILegalMonitorStateException 异常。
wait() / wait(long timeout):让当前线程进入等待状态。
notify() / notifyAll():唤醒在当前对象上等待的线程。

6.1.1 wait()

6.1.1.1 wait()方法作用

   Object o = new Object();
   o.wait();  // 让正在o对象上活动的线程进入等待状态,无期限等待,直到被唤醒为止。
   

. wait() 方法的调用,会让“正在o对象上活动的线程(当前线程)”进入等待状态,并且释放之前占有的o对象的锁。

6.1.1.2 wait()方法原理

调用 wait() 方法时,当前线程必须持有该对象的监视器锁(即 object 的锁)。

  1. 先释放锁(前提是需要先有同步锁,因此wait操作需要搭配synchronized来使用)。
  2. 进入等待池阻塞等待。
  3. 收到通知后会唤醒等待池中的一个或所有线程,重新尝试获取锁,被唤醒的线程将尝试重新获得锁,一旦获得锁,就会离开等待状态。

案例:


public class ThreadDemo9 {
    public static void main(String[] args) throws InterruptedException {
        Object object = new Object();
        synchronized (object) {
            System.out.println("wait之前");
           
            object.wait();
            
            System.out.println("wait之后");
        }
    }
}


//  调用 wait() 方法时,当前线程必须持有该对象的监视器锁(即 object 的锁),所以要搭配synchronized使用
//  当线程调用wait() 方法后,它会释放所持有的锁,并进入等待池,直到以下几种情况发生之一:

//  1. 另一个线程调用 object.notify() 或 object.notifyAll() 方法,这会唤醒等待池中的一个或所有线程。
//     被唤醒的线程将尝试重新获得锁,一旦获得锁,就会离开等待状态。
//  2. 等待的线程被中断。
//  3. 等待的线程超时。

//  在 wait() 调用之后的代码 System.out.println("wait之后"); 永远不会执行,
//  因为 wait() 导致了当前线程的阻塞,并且只有在线程被唤醒并成功获取锁后才会继续执行。
//  如果没有其他线程通过 synchronized(object) 代码块获取 object 的锁并调用 object.notify() 或 object.notifyAll(),
//  那么当前线程将一直处于等待状态。

6.1.2 notify() / notifyAll()

6.1.2.1 notify() / notifyAll()方法的作用

  Object o = new Object();
  o.notify(); // 唤醒正在o对象上等待的线程。
  o.notifyAll()  // 这个方法是唤醒o对象上处于等待的所有线程。

o.notify() / o.notifyAll() 方法只会通知唤醒o对象上处于等待的线程,不会释放之前占有的o对象的锁。

案例一:


 public static void main(String[] args) throws InterruptedException {
        Object object = new Object();

        //这个线程负责等待
        Thread t1 = new Thread(()->{
            System.out.println("t1  wait之前");
            try {
                synchronized (object){
                    object.wait();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("t1  wait之后");
        });

        //这个线程负责notify
        Thread t2 = new Thread(()->{
            System.out.println("t2  notify之前");
            try {
                Thread .sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (object) {
                object.notify();//notify务必获取到锁,才能进行通知
            }
            System.out.println("t2  notify之后");
        });

        t1.start();
        Thread.sleep(500);
        t2.start();
    }
    
// 运行结果:

t1  wait之前    // 先执行了wait,发生阻塞,没有看到wait之后的打印
t2  notify之前  //  执行到了 t2
t2  notify之后  // t2 进行notify后把 t1 的wait唤醒
t1  wait之后  // t1 继续执行

Process finished with exit code 0

案例二:

// 有三个线程,分别只能打印A,B,C,控制三个线程固定按照ABC的顺序来打印。
public static void main(String[] args) throws InterruptedException {
        Object locker1 = new Object();
        Object locker2 = new Object();

        Thread t1 = new Thread(()->{
            System.out.println("A");

            synchronized (locker1) {
                locker1.notify();
            }

        });
        Thread t2 = new Thread(()->{

            // 一进来先阻塞等待
            synchronized (locker1) {
                try {
                    locker1.wait(); //注意防止先notify后wait,需要让t2的wait先于t1的notify执行
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

            System.out.println("B");

            // t2 打印完“B”之后通知 t3 打印“C”
            synchronized (locker2) {
                locker2.notify();
            }
        });

        Thread t3 = new Thread(()->{

            // 一进来就先阻塞等待
            synchronized (locker2) {
                try {
                    locker2.wait();//注意防止先notify后wait,需要让t3的wait先于t2的notify执行
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("C");
        });
        t2.start();
        t3.start();
        Thread.sleep(1000);
        t1.start();//让t1执行的慢一点
    }
}

6.1.3 wait 和 sleep 的对比

理论上 wait 和 sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间。

  1. wait() 需要搭配 synchronized 使用. sleep 不需要
  2. wait 是 Object 的方法 sleep 是 Thread 的静态方法.
  3. 唯一的相同点就是都可以让线程放弃执行一段时间

6.2 单例模式

针对需求场景通过Java现有的语法,达成了某个类只能被创建出一个实例这样的效果,创建了多个实例就会编译报错。

6.2.1 饿汉模式

class Singleton {
    // 先把实例创建出来
    private static Singleton instance = new Singleton();

    //使用这个唯一实例,统一通过Singleton.getInstance()方法来获取
    public static Singleton getInstance(){
        return instance;
    }
    //为了避免Singleton类不小心被复制多份出来
    //把构造方法设为private,在类外面就无法通过new的方式来创造Singleton实例了
    private Singleton(){}

}

public class ThreadDemo12 {

    public static void main(String[] args) {
        Singleton s1 = Singleton.getInstance();
        Singleton s2 = Singleton.getInstance();
        System.out.println(s1==s2); // true

        //Singleton s3 = new Singleton();//编译报错,无法创建出多个实例

    }
}

Singleton和实例无关和类相关
static是单例模式的灵魂,保证这个实例唯一,保证这个实例在类加载的时候就被创建出来,让当前instance属性是类属性
类属性是类对象的属性,类对象又是唯一实例(类加载阶段被创建出的一个实例)
类加载阶段:运行一个Java程序,就需要让Java进程能够找到并读取对应.class文件内容,并解析,构造成类对象……这一系列操作的过程

6.2.2 懒汉模式

6.2.2.1 懒汉模式 (线程不安全版)
class SingletonLazy{

        //把构造方法设为private,在类外面就无法通过new的方式来创造Singleton实例了
        private SingletonLazy(){}
      
        private static SingletonLazy instance = null; // 创建了一个实例的引用并指向null
        // 这个实例并非是类加载的时候创建,
        // 而是真正第一次使用的时候,才去创建的(如果不用就不创建)
        
        // 使用这个唯一实例,统一通过Singleton.getInstance()方法来获取
        public static SingletonLazy getInstance(){
            if(instance == null) {
                // 这里有读,比较和写操作而且不是原子的,是线程不安全的              
                // 线程安全问题发生在首次创建实例时. 
                // 如果在多个线程中同时调用 getInstance 方法, 就可能导致创建出多个实例
                
                instance = new SingletonLazy();

            }
            return instance;
        }
        
}

public class ThreadDemo13 {

}
6.2.2.2 懒汉模式 (线程安全1.0版)

// 版本一:
class SingletonLazy{
        private SingletonLazy(){}
        private static SingletonLazy instance = null;
        public static SingletonLazy getInstance(){
        
            synchronized (SingletonLazy.class) {
             //这里的加锁是在new出对象之前加上是有必要的,
             
             // 加锁会使开销变大
                if(instance == null) {
              //一旦对象new完了后续调用getInstance时,此时instance的值一定是非空的,
              //因此就会直接触发return,相当于一个是比较操作 一个是 返回操作,
              //这两个操作都是读操作不加锁也没有线程安全问题
                    instance = new SingletonLazy();
                }
            }
            return instance;
        }
        
}

// 版本二:
class Singleton {
    private static Singleton instance = null;
    private Singleton() {}
    public synchronized static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
       }
        return instance;
   }
}

public class ThreadDemo13 {
}
6.2.2.3 懒汉模式 (线程安全2.0版)
class SingletonLazy{
       private static SingletonLazy instance = null;
       public static SingletonLazy getInstance(){
       
         if(instance == null) { 
         // 当没有instance对象的时候才加锁,减少不必要的开销
        	  synchronized (SingletonLazy.class) { 
             	   if(instance == null) {
                 	 instance = new SingletonLazy();
            	   }
       		  }
          }
           return instance;
       }
       private SingletonLazy(){}
}

public class ThreadDemo13 {
}

6.2.2.4 懒汉模式 (线程安全3.0版)
class Singleton {
    private static volatile Singleton instance = null;
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
           if (instance == null) {
               instance = new Singleton();
               }
           }
       }
        return instance;
   }
}

使用双重 if 判定, 降低锁竞争的频率,给 instance 加上了 volatile
加锁 / 解锁是一件开销比较高的事情. 而懒汉模式的线程不安全只是发生在首次创建实例的时候. 因此后续使用的时候, 不必再进行加锁了。
外层的 if 就是判定下看当前是否已经把 instance 实例创建出来了,同时为了避免 “内存可见性” 导致读取的 instance 出现偏差, 于是补充上 volatile。
当多线程首次调用 getInstance可能都发现 instance 为 null, 于是继续往下执行来竞争锁, 其中竞争成功的线程, 再完成创建实例的操作,当实例创建完了之后, 其他竞争到锁的线程就被里层 if 挡住. 也就不会继续创建其他实例。

具体执行流程:
假设有多个线程, 开始执行 getInstance , 通过外层的 if (instance == null) 知道了实例还没有创建的消息. 于是开始竞争同一把锁。其中线程1 率先获取到锁, 此时线程1 通过里层的 if (instance == null) 进一步确认实例是否已经创建. 如果没创建, 就把这个实例创建出来.当线程1 释放锁之后, 线程2 或 线程3 也拿到锁, 也通过里层的 if (instance == null) 来确认实例是否已经创建, 发现实例已经创建出来了, 就不再创建了.后续的线程, 不必加锁, 直接就通过外层 if (instance == null) 就知道实例已经创建了, 从而不再尝试获取锁了. 降低了开销。

6.3 阻塞式队列

阻塞队列是一种特殊的队列. 也遵守 “先进先出” 的原则.
阻塞队列能是一种线程安全的数据结构, 并且具有以下特性:
当队列满的时候, 继续入队列就会阻塞, 直到有其他线程从队列中取走元素.
当队列空的时候, 继续出队列也会阻塞, 直到有其他线程往队列中插入元素.
阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力,可以有效进行 “削峰”。
阻塞队列也能使生产者和消费者之间 解耦.

6.3.1 标准库中的阻塞队列

在 Java 标准库中内置了阻塞队列. 如果我们需要在一些程序中使用阻塞队列, 直接使用标准库中的即可
BlockingQueue 是一个接口. 真正实现的类是 LinkedBlockingQueue
put 方法用于阻塞式的入队列, take 用于阻塞式的出队列.
BlockingQueue 也有 offer, poll, peek 等方法, 但是这些方法不带有阻塞特性

案例:

	BlockingQueue<String> queue = new LinkedBlockingQueue<>();
	 // 入队列
	  queue.put("abc");
	 // 出队列. 如果没有 put 直接 take, 就会阻塞. 
	String elem = queue.take();

6.3.2 阻塞队列实现

通过 “循环队列” 的方式来实现.
使用 synchronized 进行加锁控制.
put 插入元素的时候, 判定如果队列满了, 就进行 wait. (注意, 要在循环中进行 wait. 被唤醒时不一定队列就不满了, 因为同时可能是唤醒了多个线程).
take 取出元素的时候, 判定如果队列为空, 就进行 wait. (也是循环 wait)

案例:


public class BlockingQueue {
    private int[] items = new int[1000];
    private volatile int size = 0;
    private int head = 0;
    private int tail = 0;
    public void put(int value) throws InterruptedException {
        synchronized (this) {
            // 此处最好使用 while.
            // 否则 notifyAll 的时候, 该线程从 wait 中被唤醒,
            // 但是紧接着并未抢占到锁. 当锁被抢占的时候, 可能又已经队列满了
            // 就只能继续等待
            while (size == items.length) {
                wait();
           }
            items[tail] = value;
            tail = (tail + 1) % items.length;
            size++;
            notifyAll();
       }
   }
    public int take() throws InterruptedException {
        int ret = 0;
        synchronized (this) {
            while (size == 0) {
             wait();
           }
            ret = items[head];
            head = (head + 1) % items.length;
            size--;
            notifyAll();
       }
        return ret;
   }
    public synchronized int size() {
        return size;
   }
    // 测试代码
    public static void main(String[] args) throws InterruptedException {
        BlockingQueue blockingQueue = new BlockingQueue();
        Thread customer = new Thread(() -> {
            while (true) {
                try {
                    int value = blockingQueue.take();
                    System.out.println(value);
               } catch (InterruptedException e) {
                    e.printStackTrace();
               }
           }
       }, "消费者");
        customer.start();
        Thread producer = new Thread(() -> {
            Random random = new Random();
            while (true) {
                try {
                    blockingQueue.put(random.nextInt(10000));
               } catch (InterruptedException e) {
                    e.printStackTrace();
               }
           }
       }, "生产者");
        producer.start();
        customer.join();
        producer.join();
   }
}
 

6.3.3 生产者消费者模型

"生产者消费者模型"是阻塞队列的一个典型应用场景。
生产者消费者模型就是通过一个容器来解决生产者和消费者的强耦合问题。
生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取.
案例一:


public  class Demo01{
    public static void main(String[] args) {
        Product p = new Product(); //产品
        //创建生产对象
        Producer producer = new Producer(p);
        //创建消费者
        Customer customer = new Customer(p);
        //调用start方法开启线程
        producer.start();
        customer.start();
    }
 
}
//产品类
class Product {
    String name; //名字
    double price;//价格
    boolean flag = false; //产品是否生产完毕的标识,默认情况是没有生产完成
}
//生产者
class Producer extends Thread{
    Product p; //产品
    public Producer(Product p){
        this.p = p;
    }
    @Override
    public void run(){
        while (true){
            synchronized (p){
                if (p.flag==false){
                    p.name = "苹果";
                    p.price = 6.5; 
                    System.out.println("生产者生产出了:"+p.name+"价格是:"+p.price);
                    p.flag=true;
                    p.notify();//换新消费者去消费
                }else {
                    //已经生产完毕,等待消费者先去消费
                    try{
                        p.wait(); // 让生产者等待
                    }catch (InterruptedException e){
                        e.printStackTrace();
                    }
                    
                }
            }
        }
    }
}
//消费者
class Customer extends Thread{
    Product p;
    public Customer(Product p){
        this.p = p;
    }
    @Override
    public void run(){
        while (true){
            synchronized (p){
                if (p.flag==true){//产品已经生产完毕
                    System.out.println("消费者消费了:"+p.name+"价格:"+p.price);
                    p.flag = false; // 产品已经消费完毕
                    p.notify();//唤醒生产者去生产
                }else{
                     //产品还没有生产,应该等待生产者先生产
                        try {
                            p.wait(); // 消费者等待
                        } catch (InterruptedException e) {
                            e.printStackTrace();
 
                    }
                }
            }
        }
    }
 
}

案例二:


public static void main(String[] args) throws InterruptedException {
    BlockingQueue<Integer> blockingQueue = new LinkedBlockingQueue<Integer>();
    Thread customer = new Thread(() -> {
        while (true) {
            try {
                int value = blockingQueue.take();
                System.out.println("消费元素: " + value);
           } catch (InterruptedException e) {
                e.printStackTrace();
           }
       }
   }, "消费者");
      customer.start();
    Thread producer = new Thread(() -> {
        Random random = new Random();
        while (true) {
            try {
                int num = random.nextInt(1000);
                System.out.println("生产元素: " + num);
                blockingQueue.put(num);
                Thread.sleep(1000);
           } catch (InterruptedException e) {
                e.printStackTrace();
           }
       }
   }, "生产者");
    producer.start();
    customer.join();
    producer.join();
}

6.4 定时器

6.4.1 定时器是什么

定时器也是软件开发中的一个重要组件. 类似于一个 “闹钟”. 达到一个设定的时间之后, 就执行某个指定好的代码.
比如网络通信中, 如果对方 500ms 内没有返回数据, 则断开连接尝试重连.
比如一个 Map, 希望里面的某个 key 在 3s 之后过期(自动删除).
类似于这样的场景就需要用到定时器.

6.4.2 标准库中的定时器

标准库中提供了一个 Timer 类. Timer 类的核心方法为 schedule,schedule 包含两个参数. 第一个参数指定即将要执行的任务代码, 第二个参数指定多长时间之后执行 (单位为毫秒).

Timer timer = new Timer();
timer.schedule(new TimerTask() {
    @Override
    public void run() {
        System.out.println("hello");
   }
}, 3000);

6.4.3 实现定时器

6.4.3.1 定时器的构成

一个带优先级的阻塞队列。阻塞队列中的任务都有各自的执行时刻 (delay). 最先执行的任务一定是 delay 最小的. 使用带优先级的队列就可以高效的把这个 delay 最小的任务找出来。
队列中的每个元素是一个 Task 对象。
Task 中带有一个时间属性, 队首元素就是即将。
同时有一个 worker 线程一直扫描队首元素, 看队首元素是否需要执。

6.4.3.1.1 模块
  1. Timer 类提供的核心接口为 schedule, 用于注册一个任务, 并指定这个任务多长时间后执行

public class Timer {
    public void schedule(Runnable command, long after) {
 // TODO
   }
}

  1. Task 类用于描述一个任务(作为 Timer 的内部类). 里面包含一个 Runnable 对象和一个 time(毫秒时间戳),这个对象需要放到 优先队列 中. 因此需要实现 Comparable 接口.
static class Task implements Comparable<Task> {
        private Runnable command;
        private long time;
        public Task(Runnable command, long time) {
            this.command = command;
            // time 中存的是绝对时间, 超过这个时间的任务就应该被执行
            this.time = System.currentTimeMillis() + time;
       }
        public void run() {
            command.run();
       }
        @Override
        public int compareTo(Task o) {
            // 谁的时间小谁排前面
            return (int)(time - o.time);
       }
   }
}
  1. Timer 实例中, 通过 PriorityBlockingQueue 来组织若干个 Task 对象. 通过 schedule 来往队列中插入一个个 Task 对象。
class Timer {
    // 核心结构
    private PriorityBlockingQueue<Task> queue = new PriorityBlockingQueue();
    
    public void schedule(Runnable command, long after) {
        Task task = new Task(command, after);
        queue.offer(task);
   }    
}
  1. Timer 类中存在一个 worker 线程, 一直不停的扫描队首元素, 看看是否能执行这个任务.,所谓 “能执行” 指的是该任务设定的时间已经到达了.
class Timer {
 // ... 前面的代码不变
    
    public Timer() {
        // 启动 worker 线程
        Worker worker = new Worker();
        worker.start();
   }
    
    class Worker extends Thread{
        @Override
        public void run() {
            while (true) {
                try {
                    Task task = queue.take();
                    long curTime = System.currentTimeMillis();
                    if (task.time > curTime) {
                        // 时间还没到, 就把任务再塞回去
                        queue.put(task);
                   } else {
                        // 时间到了, 可以执行任务
                        task.run();
                   }
               } catch (InterruptedException e) {
                    e.printStackTrace();
                    break;
               }
           }
       }
   }
}

但是当前这个代码中存在一个严重的问题, 就是 while (true) 转的太快了, 造成了无意义的 CPU 浪费.。比如第一个任务设定的是 1 min 之后执行某个逻辑. 但是这里的 while (true) 会导致每秒钟访问队首元素几万次. 而当前距离任务执行的时间还有很久呢。

  1. 引入一个 mailBox 对象, 借助该对象的 wait / notify 来解决 while (true) 的忙等问题.
class Timer {
    // 存在的意义是避免 worker 线程出现忙等的情况
    private Object mailBox = new Object(); 
}

修改 Worker 的 run 方法, 引入 wait, 等待一定的时间.

public void run() {
    while (true) {
        try {
            Task task = queue.take();
            long curTime = System.currentTimeMillis();
            if (task.time > curTime) {
                // 时间还没到, 就把任务再塞回去
                queue.put(task);
             // [引入 wait] 等待时间按照队首元素的时间来设定. 
                synchronized (mailBox) {
                    // 指定等待时间 wait
                    mailBox.wait(task.time - curTime);
               }
                
           } else {
                // 时间到了, 可以执行任务
                task.run();
           }
       } catch (InterruptedException e) {
            e.printStackTrace();
            break;
       }
   }
}
     

修改 Timer 的 schedule 方法, 每次有新任务到来的时候唤醒一下 worker 线程. (因为新插入的任务可能是需要马上执行的).

public void schedule(Runnable command, long after) {
    Task task = new Task(command, after);
    queue.offer(task);
    
    // [引入 notify] 每次有新的任务来了, 都唤醒一下 worker 线程, 检测下当前是否有
    synchronized (mailBox) {
        mailBox.notify();
   }
}

完整代码:

/**
* 定时器的构成:
* 一个带优先级的阻塞队列
* 队列中的每个元素是一个 Task 对象.
* Task 中带有一个时间属性, 队首元素就是即将
* 同时有一个 worker 线程一直扫描队首元素, 看队首元素是否需要执行
*/
public class Timer {
    static class Task implements Comparable<Task> {
        private Runnable command;
        private long time;
        public Task(Runnable command, long time) {
            this.command = command;
            // time 中存的是绝对时间, 超过这个时间的任务就应该被执行
            this.time = System.currentTimeMillis() + time;
       }
        public void run() {
            command.run();
       }
         @Override
        public int compareTo(Task o) {
            // 谁的时间小谁排前面
            return (int)(time - o.time);
       }
   }
    // 核心结构
    private PriorityBlockingQueue<Task> queue = new PriorityBlockingQueue();
    // 存在的意义是避免 worker 线程出现忙等的情况
    private Object mailBox = new Object();
    class Worker extends Thread{
        @Override
        public void run() {
            while (true) {
                try {
                    Task task = queue.take();
                    long curTime = System.currentTimeMillis();
                    if (task.time > curTime) {
                        // 时间还没到, 就把任务再塞回去
queue.put(task);
                        synchronized (mailBox) {
                            // 指定等待时间 wait
mailBox.wait(task.time - curTime);
                       }
                   } else {
                        // 时间到了, 可以执行任务
task.run();
                   }
               } catch (InterruptedException e) {
                    e.printStackTrace();
                    break;
               }
           }
       }
   }
    public Timer() {
        // 启动 worker 线程
        Worker worker = new Worker();
        worker.start();
   }
    // schedule 原意为 "安排"
    public void schedule(Runnable command, long after) {
        Task task = new Task(command, after);
        queue.offer(task);
        synchronized (mailBox) {
            mailBox.notify();
       }
   }
    public static void main(String[] args) {
        Timer timer = new Timer();
        Runnable command = new Runnable() {
            @Override
            public void run() {
               System.out.println("我来了");
                timer.schedule(this, 3000);
           }
       };
        timer.schedule(command, 3000);
   }
}
            

6.5 线程池

6.5.1 线程池是什么

如果某个线程不再使用了, 并不是真正把线程释放, 而是放到一个 “池子”
中, 下次如果需要用到线程就直接从池子中取, 不必通过系统来创建了

想象这么一个场景:
在学校附近新开了一家快递店,老板很精明,想到一个与众不同的办法来经营。店里没有雇人,而是每次有业务来了,就现场找一名同学过来把快递送了,然后解雇同学。这个类比我们平时来一个任务,起一个线程进行处理的模式。很快老板发现问题来了,每次招聘 + 解雇同学的成本还是非常高的。老板还是很善于变通的,知道了为什么大家都要雇人了,所以指定了一个指标,公司业务人员会扩张到 3 个人,但还是随着业务逐步雇人。于是再有业务来了,老板就看,如果现在公司还没 3 个人,就雇一个人去送快递,否则只是把业务放到一个本本上,等着 3 个快递人员空闲的时候去处理。这个就是线程池模式。

线程池最大的好处就是减少每次启动、销毁线程的损耗。

6.5.2 标准库中的线程池——Executors类

Executors 是一个工厂类, 能够创建出几种不同风格的线程池
Executors 创建线程池的几种方式:(返回值类型为 ExecutorService)

  1. newFixedThreadPool: 创建固定线程数的线程池
  2. newCachedThreadPool: 创建线程数目动态增长的线程池.
  3. newSingleThreadExecutor: 创建只包含单个线程的线程池.
  4. newScheduledThreadPool: 设定 延迟时间后执行命令,或者定期执行命令. 是进阶版的 Timer.
    Executors 本质上是 ThreadPoolExecutor 类的封装.

案例;


// ExecutorService 表示一个线程池实例
// 使用 Executors.newFixedThreadPool(10) 能创建出固定包含 10 个线程的线程池,
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(new Runnable() {  //通过 ExecutorService 的 submit 方法能够向线程池中提交若干个任务。
    @Override
    public void run() {
        System.out.println("hello");
   }
});

6.5.2.1 Executors 的本质

Executors 本质上是 ThreadPoolExecutor 类的封装.

ThreadPoolExecutor:
ThreadPoolExecutor 的构造方法

在这里插入图片描述

理解 ThreadPoolExecutor 构造方法的参数 :

把创建一个线程池想象成开个公司. 每个员工相当于一个线程.
corePoolSize: 正式员工的数量. (正式员工, 一旦录用, 永不辞退)
maximumPoolSize: 正式员工 + 临时工的数目. (临时工: 一段时间不干活, 就被辞退).
keepAliveTime: 临时工允许的空闲时间.
unit: keepaliveTime 的时间单位, 是秒, 分钟, 还是其他值.
workQueue: 传递任务的阻塞队列
threadFactory: 创建线程的工厂, 参与具体的创建线程工作.
RejectedExecutionHandler: 拒绝策略, 如果任务量超出公司的负荷了接下来怎么处理.

AbortPolicy(): 超过负荷, 直接抛出异常.
CallerRunsPolicy(): 调用者负责处理
DiscardOldestPolicy(): 丢弃队列中最老的任务.
DiscardPolicy(): 丢弃新来的任务.

ThreadPoolExecutor的工作流程在这里插入图片描述
ThreadPoolExecutor工作原理:信号量 Semaphore

信号量用来表示 “可用资源的个数”. 本质上就是一个计数器。
可以把信号量想象成是停车场的展示牌: 当前有车位 100 个. 表示有 100 个可用资源。
当有车开进去的时候, 就相当于申请一个可用资源, 可用车位就 -1 (这个称为信号量的 P 操作)。
当有车开出来的时候, 就相当于释放一个可用资源, 可用车位就 +1 (这个称为信号量的 V 操作)。
如果计数器的值已经为 0 了, 还尝试申请资源, 就会阻塞等待, 直到有其他线程释放资源。
Semaphore 的 PV 操作中的加减计数器操作都是原子的, 可以在多线程环境下直接使用。

// 创建 Semaphore 示例, 初始化为 4, 表示有 4 个可用资源. 
Semaphore semaphore = new Semaphore(4);
Runnable runnable = new Runnable() {
  @Override
  public void run() {
      try {
          System.out.println("申请资源");
          semaphore.acquire();  // acquire 方法表示申请资源(P操作)
          System.out.println("我获取到资源了");
          Thread.sleep(1000);
          System.out.println("我释放资源了");
          semaphore.release();   // release 方法表示释放资源(V操作)
     } catch (InterruptedException e) {
          e.printStackTrace();
     }
 }
};
// 创建 20 个线程, 每个线程都尝试申请资源,
for (int i = 0; i < 20; i++) {
   Thread t = new Thread(runnable);
   t.start();
}

CountDownLatch:同时等待 N 个任务执行结束.

好像跑步比赛,10个选手依次就位,哨声响才同时出发;所有选手都通过终点,才能公布成绩。


public class Demo {
   public static void main(String[] args) throws Exception {
       CountDownLatch latch = new CountDownLatch(10);
       Runnable r = new Runable() {
           @Override
           public void run() {
               try {
                   Thread.sleep(Math.random() * 10000);
                   latch.countDown();
              } catch (Exception e) {
                   e.printStackTrace();
              }
          }
      };
       for (int i = 0; i < 10; i++) {
           new Thread(r).start();
      }
  // 必须等到 10 人全部回来
       latch.await();
       System.out.println("比赛结束");
          }
}

6.5.3 实现线程池

核心操作为 submit, 将任务加入线程池中。
使用 Worker 类描述一个工作线程. 使用 Runnable 描述一个任务。
使用一个 BlockingQueue 组织所有的任务。
每个 worker 线程要做的事情: 不停的从 BlockingQueue 中取任务并执行。
指定一下线程池中的最大线程数 maxWorkerCount; 当当前线程数超过这个最大值时, 就不再新增线程了。

class Worker extends Thread {
    private LinkedBlockingQueue<Runnable> queue = null;
    public Worker(LinkedBlockingQueue<Runnable> queue) {
        super("worker");
        this.queue = queue;
   }
    @Override
    public void run() {
        // try 必须放在 while 外头, 或者 while 里头应该影响不大
        try {
            while (!Thread.interrupted()) {
                Runnable runnable = queue.take();
                runnable.run();
           }
       } catch (InterruptedException e) {
       }
   }
}
public class MyThreadPool {
    private int maxWorkerCount = 10;
    private LinkedBlockingQueue<Runnable> queue = new LinkedBlockingQueue();
    public void submit(Runnable command) {
        if (workerList.size() < maxWorkerCount) {
            // 当前 worker 数不足, 就继续创建 worker
            Worker worker = new Worker(queue);
            worker.start();
       }
        // 将任务添加到任务队列中
        queue.put(command);
   }
    public static void main(String[] args) throws InterruptedException {
        MyThreadPool myThreadPool = new MyThreadPool();
        myThreadPool.execute(new Runnable() {
            @Override
            public void run() {
                System.out.println("吃饭");
           }
       });
       Thread.sleep(1000);
   }
} 
  • 20
    点赞
  • 54
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值