JUC并发编程原理精讲(源码分析)

1. JUC前言知识

JUC即 java.util.concurrent

涉及三个包:

  • java.util.concurrent
  • java.util.concurrent.atomic
  • java.util.concurrent.locks

普通的线程代码:

  • Thread
  • Runnable 没有返回值、效率相比入 Callable 相对较低!
  • Callable 有返回值!【工作常用】

1.1 进程和线程

进程:是指一个内存中运行的程序,每个进程都有一个独立的内存空间,一个应用程序可以同时运行多个进程。进程是资源分配的单位。

记忆:进程的英文为Process,Process也为过程,所以进程可以大概理解为程序执行的过程。

(进程也是程序的一次执行过程,是系统运行程序的基本单位; 系统运行一个程序即是一个进程从创建、运行到消亡的过程)

线程:进程中的一个执行单元,负责当前进程中程序的执行。一个进程中是可以有多个线程的。线程是CPU调度和执行的单位。

【java默认有两个线程:main、GC】


举例:打开word使用是一个进程,word会检查你的拼写,两个线程:容灾备份,语法检查


进程与线程的区别

  • 进程:有独立的内存空间,进程中的数据存放空间(堆空间和栈空间)是独立的,至少有一个线程。
  • 线程:堆空间是共享的,栈空间是独立的,线程消耗的资源比进程小的多

1.2 并发与并行

并行 :指两个或多个事件在同一时刻发生(同时发生)【多个CPU同时执行多个线程】

并发 :指两个或多个事件在同一个时间段内发生。(交替执行) 【一个CPU交替执行线程】


拓展

  1. 并发编程的本质是充分利用cpu资源

    java代码查询cpu核数:

    //查询cpu核数
    //CPU 密集型,IO密集型
    System.out.println(Runtime.getRuntime().availableProcessors());
    
  2. java真的可以开启线程吗?不能,通过源码可知底层开启线程的start()方法是native修饰的,意思是调用操作系统C++的代码

    //本地方法,底层的C++ java无法直接操作硬件
    private native void start0();
    

1.3 线程六种状态

  1. NEW(新建)
    线程刚被创建,但是并未启动。还没调用start方法
  2. Runnable(可运行)
    线程可以在java虚拟机中运行的状态,可能正在运行自己代码,也可能没有,这取决于操 作系统处理器
  3. Blocked(锁阻塞)
    当一个线程试图获取一个对象锁,而该对象锁被其他的线程持有,则该线程进入Blocked状 态;当该线程持有锁时,该线程将变成Runnable状态。
  4. Waiting(无限等待)
    一个线程在等待另一个线程执行一个(唤醒)动作时,该线程进入Waiting状态。
    进入这个 状态后是不能自动唤醒的,必须等待另一个线程调用notify或者notifyAll方法才能够唤醒。
  5. Timed Waiting(计时等待)
    同waiting状态,有几个方法有超时参数,调用他们将进入Timed Waiting状态。
    这一状态 将一直保持到超时期满或者接收到唤醒通知。带有超时参数的常用方法有Thread.sleep 、 Object.wait
  6. Teminated(被终止)
    因为run方法正常退出而死亡,或者因为没有捕获的异常终止了run方法而死亡。

上源码:

public enum State {
    /**
     * 新建
     */
    NEW,

    /**
     * 运行
     */
    RUNNABLE,

    /**
     * 阻塞
     */
    BLOCKED,

    /**
     * 等待,死死的等
     */
    WAITING,

    /**
     * 超时等待
     */
    TIMED_WAITING,

    /**
     * 停止
     */
    TERMINATED;
}

1.4 sleep与wait区别

只要是等待都需要抛出异常,中断异常

  1. 来自不同的类

    • wait -> Object

    • sleep -> Thread

  2. 关于锁的释放

    • wait会释放锁
    • sleep睡觉了,抱着锁睡觉,不会释放!
  3. 使用的范围是不同的

    • wait必须在同步代码块中
    • sleep可以在任何地方睡

1.5 解耦写线程

学生写法(多耦):

public class SaleTickerDemo01 {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();
        new Thread(ticket, "A").start();
        new Thread(ticket, "B").start();
		new Thread(ticket, "C").start();
    }
}

class Ticket implements Runnable{
    private int number = 50;

    public void run(){
        if (number > 0) {
            System.out.println(Thread.currentThread().getName() + "买了第" + (number--) + "张票");
        }
    }
}

工作写法(解耦):

public class SaleTickerDemo01 {
    public static void main(String[] args) {
        Ticket ticket = new Ticket();
        new Thread(() -> {
            for (int i = 0; i < 50; i++) {
                ticket.sale();
            }
        }, "A").start();
        new Thread(() -> {
            for (int i = 0; i < 50; i++) {
                ticket.sale();
            }
        }, "B").start();
        new Thread(() -> {
            for (int i = 0; i < 50; i++) {
                ticket.sale();
            }
        }, "C").start();

    }
}

class Ticket {
    private int number = 50;

    public synchronized void sale() {
        if (number > 0) {
            System.out.println(Thread.currentThread().getName() + "买了第" + (number--) + "张票");
        }
    }
}

1.6 锁基础

较难,能理解就理解

1.6.1 锁机制

通过使用synchronized关键字来实现锁,这样就能够很好地解决线程之间争抢资源的情况。那么,synchronized底层到是如何实现的呢?

我们知道,使用synchronized,一定是和某个对象相关联的,比如我们要对某一段代码加锁,那么我们就需要提供一个对象来作为锁本身:

public static void main(String[] args) {
    synchronized (Main.class) {
        //这里使用的是Main类的Class对象作为锁
    }
}

我们来看看,它变成字节码之后会用到哪些指令:

image

其中最关键的就是monitorenter指令了,可以看到之后也有monitorexit与之进行匹配(注意这里有2个),monitorentermonitorexit分别对应加锁和释放锁,在执行monitorenter之前需要尝试获取锁,每个对象都有一个monitor监视器与之对应,而这里正是去获取对象监视器的所有权,一旦monitor所有权被某个线程持有,那么其他线程将无法获得(管程模型的一种实现)。

在代码执行完成之后,我们可以看到,一共有两个monitorexit在等着我们,那么为什么这里会有两个呢,按理说monitorentermonitorexit不应该一一对应吗,这里为什么要释放锁两次呢?

首先我们来看第一个,这里在释放锁之后,会马上进入到一个goto指令,跳转到15行,而我们的15行对应的指令就是方法的返回指令,其实正常情况下只会执行第一个monitorexit释放锁,在释放锁之后就接着同步代码块后面的内容继续向下执行了。而第二个,其实是用来处理异常的,可以看到,它的位置是在12行,如果程序运行发生异常,那么就会执行第二个monitorexit,并且会继续向下通过athrow指令抛出异常,而不是直接跳转到15行正常运行下去。

image

实际上synchronized使用的锁就是存储在Java对象头中的,我们知道,对象是存放在堆内存中的,而每个对象内部,都有一部分空间用于存储对象头信息,而对象头信息中,则包含了Mark Word用于存放hashCode和对象的锁信息,在不同状态下,它存储的数据结构有一些不同。image


1.6.2 重量级锁

在JDK6之前,synchronized一直被称为重量级锁,monitor依赖于底层操作系统的Lock实现,Java的线程是映射到操作系统的原生线程上,切换成本较高。而在JDK6之后,锁的实现得到了改进。我们先从最原始的重量级锁开始:

我们说了,每个对象都有一个monitor与之关联,在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
}

每个等待锁的线程都会被封装成ObjectWaiter对象,进入到如下机制:

image

ObjectWaiter首先会进入 Entry Set等着,当线程获取到对象的monitor后进入 The Owner 区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1,若线程调用wait()方法,将释放当前持有的monitorowner变量恢复为nullcount自减1,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor并复位变量的值,以便其他线程进入获取对象的monitor

虽然这样的设计思路非常合理,但是在大多数应用上,每一个线程占用同步代码块的时间并不是很长,我们完全没有必要将竞争中的线程挂起然后又唤醒,并且现代CPU基本都是多核心运行的,我们可以采用一种新的思路来实现锁。

在JDK1.4.2时,引入了自旋锁(JDK6之后默认开启),它不会将处于等待状态的线程挂起,而是通过无限循环的方式,不断检测是否能够获取锁,由于单个线程占用锁的时间非常短,所以说循环次数不会太多,可能很快就能够拿到锁并运行,这就是自旋锁。当然,仅仅是在等待时间非常短的情况下,自旋锁的表现会很好,但是如果等待时间太长,由于循环是需要处理器继续运算的,所以这样只会浪费处理器资源,因此自旋锁的等待时间是有限制的,默认情况下为10次,如果失败,那么会进而采用重量级锁机制。

image

在JDK6之后,自旋锁得到了一次优化,自旋的次数限制不再是固定的,而是自适应变化的,比如在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行,那么这次自旋也是有可能成功的,所以会允许自旋更多次。当然,如果某个锁经常都自旋失败,那么有可能会不再采用自旋策略,而是直接使用重量级锁。


1.6.3 轻量级锁

从JDK 1.6开始,为了减少获得锁和释放锁带来的性能消耗,就引入了轻量级锁。

轻量级锁的目标是,在无竞争情况下,减少重量级锁产生的性能消耗(并不是为了代替重量级锁,实际上就是赌同一时间只有一个线程在占用资源),包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。它不像是重量级锁那样,需要向操作系统申请互斥量。它的运作机制如下:

在即将开始执行同步代码块中的内容时,会首先检查对象的Mark Word,查看锁对象是否被其他线程占用,如果没有任何线程占用,那么会在当前线程中所处的栈帧中建立一个名为锁记录(Lock Record)的空间,用于复制并存储对象目前的Mark Word信息(官方称为Displaced Mark Word)。

接着,虚拟机将使用CAS操作将对象的Mark Word更新为轻量级锁状态(数据结构变为指向Lock Record的指针,指向的是当前的栈帧)

CAS(Compare And Swap)是一种无锁算法(我们之前在Springboot阶段已经讲解过了),它并不会为对象加锁,而是在执行的时候,看看当前数据的值是不是我们预期的那样,如果是,那就正常进行替换,如果不是,那么就替换失败。比如有两个线程都需要修改变量i的值,默认为10,现在一个线程要将其修改为20,另一个要修改为30,如果他们都使用CAS算法,那么并不会加锁访问i,而是直接尝试修改i的值,但是在修改时,需要确认i是不是10,如果是,表示其他线程还没对其进行修改,如果不是,那么说明其他线程已经将其修改,此时不能完成修改任务,修改失败。

在CPU中,CAS操作使用的是cmpxchg指令,能够从最底层硬件层面得到效率的提升。

如果CAS操作失败了的话,那么说明可能这时有线程已经进入这个同步代码块了,这时虚拟机会再次检查对象的Mark Word,是否指向当前线程的栈帧,如果是,说明不是其他线程,而是当前线程已经有了这个对象的锁,直接放心大胆进同步代码块即可。如果不是,那确实是被其他线程占用了。

这时,轻量级锁一开始的想法就是错的(这时有对象在竞争资源,已经赌输了),所以说只能将锁膨胀为重量级锁,按照重量级锁的操作执行(注意锁的膨胀是不可逆的)image

所以,轻量级锁 -> 失败 -> 自适应自旋锁 -> 失败 -> 重量级锁

解锁过程同样采用CAS算法,如果对象的MarkWord仍然指向线程的锁记录,那么就用CAS操作把对象的MarkWord和复制到栈帧中的Displaced Mark Word进行交换。如果替换失败,说明其他线程尝试过获取该锁,在释放锁的同时,需要唤醒被挂起的线程。


轻量级锁的加锁过程:

(1)当线程执行代码进入同步块时,若Mark Word为无锁状态,虚拟机先在当前线程的栈帧中建立一个名为Lock Record的空间,用于存储当前对象的Mark Word的拷贝,官方称之为“Dispalced Mark Word”

(2)复制对象头中的Mark Word到锁记录中。

(3)复制成功后,虚拟机将用CAS操作将对象的Mark Word更新为执行Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。如果更新成功,则执行4,否则执行5。;

(4)如果更新成功,则这个线程拥有了这个锁,并将锁标志设为00,表示处于轻量级锁状态

(5)如果更新失败,虚拟机会检查对象的Mark Word是否指向当前线程的栈帧,如果是则说明当前线程已经拥有这个锁,可进入执行同步代码。否则说明多个线程竞争,轻量级锁就会膨胀为重量级锁,Mark Word中存储重量级锁(互斥锁)的指针,后面等待锁的线程也要进入阻塞状态。


1.6.4 偏向锁

偏向锁相比轻量级锁更纯粹,干脆就把整个同步都消除掉,不需要再进行CAS操作了。它的出现主要是得益于人们发现某些情况下某个锁频繁地被同一个线程获取,这种情况下,我们可以对轻量级锁进一步优化:偏向锁实际上就是专门为单个线程而生的,当某个线程第一次获得锁时,如果接下来都没有其他线程获取此锁,那么持有锁的线程将不再需要进行同步操作。

通俗的讲,偏向锁就是在运行过程中,对象的锁偏向某个线程。即在开启偏向锁机制的情况下,某个线程获得锁,当该线程下次再想要获得锁时,不需要再获得锁(即忽略synchronized关键词),直接就可以执行同步代码,比较适合竞争较少的情况。

可以从之前的MarkWord结构中看到,偏向锁也会通过CAS操作记录线程的ID,如果一直都是同一个线程获取此锁,那么完全没有必要在进行额外的CAS操作。当然,如果有其他线程来抢了,那么偏向锁会根据当前状态,决定是否要恢复到未锁定或是膨胀为轻量级锁。

如果我们需要使用偏向锁,可以添加-XX:+UseBiased参数来开启

所以,最终的锁等级为:未锁定 < 偏向锁 < 轻量级锁 < 重量级锁

值得注意的是,如果对象通过调用hashCode()方法计算过对象的一致性哈希值,那么它是不支持偏向锁的,会直接进入到轻量级锁状态,因为Hash是需要被保存的,而偏向锁的Mark Word数据结构,无法保存Hash值;如果对象已经是偏向锁状态,再去调用hashCode()方法,那么会直接将锁升级为重量级锁,并将哈希值存放在monitor(有预留位置保存)中。

image

偏向锁的获取流程:

(1)查看Mark Word中偏向锁的标识以及锁标志位,若是否偏向锁为1且锁标志位为01,则该锁为可偏向状态。

(2)若为可偏向状态,则测试Mark Word中的线程ID是否与当前线程相同,若相同,则直接执行同步代码,否则进入下一步。

(3)当前线程通过CAS操作竞争锁,若竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行同步代码,若竞争失败,进入下一步。

(4)当前线程通过CAS竞争锁失败的情况下,说明有竞争。当到达全局安全点时之前获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。


偏向锁的释放流程:

偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁状态的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销需要等待全局安全点(即没有字节码正在执行),它会暂停拥有偏向锁的线程,撤销后偏向锁恢复到未锁定状态或轻量级锁状态。


1.6.5 锁消除和锁粗化

锁消除和锁粗化都是在运行时的一些优化方案。

  1. 锁消除是比如我们某段代码虽然加了锁,但是在运行时根本不可能出现各个线程之间资源争夺的情况,这种情况下,完全不需要任何加锁机制,所以锁会被消除。
  2. 锁粗化则是我们代码中频繁地出现互斥同步操作,比如在一个循环内部加锁,这样明显是非常消耗性能的,所以虚拟机一旦检测到这种操作,会将整个同步范围进行扩展。

2. Lock锁

2.0 Lock锁和synchronized的区别

  1. Synchronized是内置Java关键字;Lock是一个Java类。
  2. Synchronized无法判断获取锁的状态;Lock可以判断是否获取到了锁。(boolean b = lock.tryLock();)
  3. Synchronized会自动释放锁;Lock必须要手动释放锁,如果不释放锁,死锁。
  4. Synchronized线程1获得锁阻塞时,线程2会一直等待下去;Lock锁线程1获得锁阻塞时,线程2等待足够长的时间后中断等待,去做其他的事。
  5. Synchronized可重入锁,不可以中断的,非公平;Lock,可重入锁,可以判断锁,非公平(可以自己设置)。
    lock.lockInterruptibly();方法:当两个线程同时通过该方法想获取某个锁时,假若此时线程A获取到了锁,而线程B只有在等待,那么对线程B调用threadB.interrupt()方法能够中断线程B的等待过程。
  6. Synchronized适合锁少量的代码同步问题;Lock适合锁大量的同步代码。

2.1 Lock接口的三个实现类

image

由jdk查询可知,有三种类


2.2 ReentrantLock类

ReentrantLock锁的对象是调用lock方法的实例对象

使用创建ReentrantLock对象代替传统的Synchronized锁

2.2.1 构造方法——公平锁and非公平锁

公平锁:十分公平,不能插队。
非公平锁:十分不公平,可以插队。(默认非公平锁)

image

需要更改默认的非公平锁为公平锁,需要在创建对象的时候参数设置为true(默认是false)


2.2.2 ReentrantLock类的使用

class X {
    private final ReentrantLock lock = new ReentrantLock();
    // ...
    public void m() {
        lock.lock();  // block until condition holds
        try {
            //业务代码 ... method body
        } finally {
            lock.unlock();
        }
    }
}

2.3 Condition接口

使用await和signal方法代替传统的wait和notify方法

image

image


2.3.1 await() signal() 方法基本使用

就是在最原始的多线程synchronized写法上修改了使用的方法

使用while的缘故还是:可能会出现虚假唤醒

image

image


2.3.2 Condition实现精准通知唤醒

使用ReentrantLock创建的对象lock来创建多个condition对象,每次等待和唤醒都可以指定 如:conditionA.await(); conditionB.signal();

举例:

public class C {
    public static void main(String[] args) {
        Data3 data3 = new Data3();
        //A执行完,调用B,B执行完,调用C,C执行完,调用A
        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                data3.printA();
            }
        }, "A").start();
        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                data3.printB();
            }
        }, "B").start();
        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                data3.printC();
            }
        }, "C").start();

    }
}

class Data3 {
    private Lock lock = new ReentrantLock();
    Condition conditionA = lock.newCondition();
    Condition conditionB = lock.newCondition();
    Condition conditionC = lock.newCondition();
    private char ch = 'A';

    public void printA() {
        lock.lock();
        try {
            while (ch != 'A') {
                //等待
                conditionA.await();
            }
            System.out.println(Thread.currentThread().getName() + "--->A");
            //唤醒
            ch = 'B';
            conditionB.signal();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    public void printB() {
        lock.lock();
        try {
            while (ch != 'B') {
                //等待
                conditionB.await();
            }
            System.out.println(Thread.currentThread().getName() + "--->B");
            //唤醒
            ch = 'C';
            conditionC.signal();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }

    }

    public void printC() {
        lock.lock();
        try {
            while (ch != 'C') {
                //等待
                conditionC.await();
            }
            System.out.println(Thread.currentThread().getName() + "--->C");
            //唤醒
            ch = 'A';
            conditionA.signal();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}

结果:执行顺序变成以此执行

A--->A
B--->B
C--->C
A--->A
B--->B
C--->C
A--->A
B--->B
C--->C
A--->A
B--->B
C--->C
A--->A
B--->B
C--->C
A--->A
B--->B
C--->C
A--->A
B--->B
C--->C
A--->A
B--->B
C--->C
A--->A
B--->B
C--->C
A--->A
B--->B
C--->C

3. 生产者与消费者

3.1 传统的synchronized写法

synchronized+wait+notifyall

public class A {
    public static void main(String[] args) {
        Data data = new Data();
        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                try {
                    data.increment();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "A").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                try {
                    data.increment();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "B").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                try {
                    data.decrement();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "C").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                try {
                    data.decrement();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "D").start();
    }
}

class Data {
    private int number = 0;

    public synchronized void increment() throws InterruptedException {
        if (number != 0) {
            //等待
            this.wait();
        }
        number++;
        System.out.println(Thread.currentThread().getName() + "==>" + number);
        //通知其他线程,我+1完毕了
        this.notifyAll();
    }

    public synchronized void decrement() throws InterruptedException {
        if (number == 0) {
            //等待
            this.wait();
        }
        number--;
        System.out.println(Thread.currentThread().getName() + "==>" + number);
        //通知其他线程,我-1完毕了
        this.notifyAll();
    }
}

输出:发现有问题,出现了负数和大于1的数,这就是虚假唤醒问题(看下面)

A==>1
C==>0
B==>1
A==>2
B==>3
C==>2
C==>1
C==>0
B==>1
A==>2
B==>3
C==>2
C==>1
C==>0
B==>1
A==>2
B==>3
D==>2
D==>1
D==>0
C==>-1
C==>-2
C==>-3
D==>-4
D==>-5
D==>-6
D==>-7
D==>-8
D==>-9
D==>-10
B==>-9
A==>-8
B==>-7
A==>-6
B==>-5
A==>-4
B==>-3
A==>-2

3.2 Lock写法

ReentrantLock类 和 Condition接口:lock+await+signal

public class PC {
    public static void main(String[] args) {
        Data data = new Data();
        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                data.decrement();
            }
        },"A").start();
        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                data.increment();
            }
        },"B").start();
        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                data.decrement();
            }
        },"C").start();
        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                data.increment();
            }
        },"D").start();
    }
}
class Data {
    private int number = 0;
    Lock lock = new ReentrantLock();
    Condition condition = lock.newCondition();
    public void increment() {
        lock.lock();
        try {
            if (number > 0) {
                try {
                    condition.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            number++;
            System.out.println(Thread.currentThread().getName() + "=>" + number);
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }
    public void decrement() {
        lock.lock();
        try {
            if (number <= 0) {
                try {
                    condition.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            number--;
            System.out.println(Thread.currentThread().getName() + "=>" + number);
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }
}

输出:发现有问题,出现了负数,这就是虚假唤醒问题(看下面)

B=>1
A=>0
B=>1
A=>0
C=>-1
B=>0
B=>1
A=>0
C=>-1
B=>0
B=>1
A=>0
C=>-1
D=>0
D=>1
B=>2
A=>1
A=>0
C=>-1
D=>0
D=>1
B=>2
A=>1
A=>0
C=>-1
D=>0
D=>1
B=>2
A=>1
A=>0
C=>-1
D=>0
D=>1
B=>2
C=>1
C=>0
D=>1
C=>0
D=>1
C=>0


3.2 虚假唤醒问题

3.2.1 问题描述

白话:一个if条件里有等待语句,两个消费者都进入这个等待;当生产者生产了1数量的产品并唤醒消费者,此时之前处于等待的两个消费者会被唤醒,然后进行消费;导致最后消费的产品出现负数,因为产品只有一个,而消费者消费了两次。

虚假唤醒是一种现象,它只会出现在多线程环境中,指的是在多线程环境下,多个线程等待在同一个条件上,等到条件满足时,所有等待的线程都被唤醒,但由于多个线程执行的顺序不同,后面竞争到锁的线程在获得时间片时条件已经不再满足,线程应该继续睡眠但是却继续往下运行的一种现象。

举例:

public class A {
    public static void main(String[] args) {
        Data data = new Data();
        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                try {
                    data.increment();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "A").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                try {
                    data.increment();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "B").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                try {
                    data.decrement();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "C").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                try {
                    data.decrement();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "D").start();
    }
}

class Data {
    private int number = 0;

    public synchronized void increment() throws InterruptedException {
        if (number != 0) {
            //等待
            this.wait();
        }
        number++;
        System.out.println(Thread.currentThread().getName() + "==>" + number);
        //通知其他线程,我+1完毕了
        this.notifyAll();
    }

    public synchronized void decrement() throws InterruptedException {
        if (number == 0) {
            //等待
            this.wait();
        }
        number--;
        System.out.println(Thread.currentThread().getName() + "==>" + number);
        //通知其他线程,我-1完毕了
        this.notifyAll();
    }
}

结果:出现了大于1的情况,以及小于0的情况

A==>1
C==>0
B==>1
A==>2
B==>3
C==>2
C==>1
C==>0
B==>1
A==>2
B==>3
C==>2
C==>1
C==>0
B==>1
A==>2
B==>3
D==>2
D==>1
D==>0
C==>-1
C==>-2
C==>-3
D==>-4
D==>-5
D==>-6
D==>-7
D==>-8
D==>-9
D==>-10
B==>-9
A==>-8
B==>-7
A==>-6
B==>-5
A==>-4
B==>-3
A==>-2

3.2.2 解决方法

image

👆 jdk8给出的解决方案为:将增加和减少的方法中的if修改为while。如:

用if判断的话,唤醒后线程会从wait之后的代码开始运行,但是不会重新判断if条件,直接继续运行if代码块之后的代码,而如果使用while的话,也会从wait之后的代码运行,但是唤醒后会重新判断循环条件,如果不成立再执行while代码块之后的代码块,成立的话继续wait。


解决方案原理:

拿两个加法线程A、B来说,比如A先执行,执行时调用了wait方法,那它会等待,此时会释放锁,那么线程B获得锁并且也会执行wait方法,两个加线程一起等待被唤醒。此时减线程中的某一个线程执行完毕并且唤醒了这俩加线程,那么这俩加线程不会一起执行,其中A获取了锁并且加1,执行完毕之后B再执行。如果是if的话,那么A修改完num后,唤醒其他线程并释放锁,此时B抢到了锁,B不会再去判断num的值,直接会给num+1。如果是while的话,A执行完之后,B抢到了锁的话B还会去判断num的值,因此就不会执行。


synchronized的最终版本:

public class A {
    public static void main(String[] args) {
        Data data = new Data();
        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                data.increment();
            }
        }, "A").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                data.decrement();
            }
        }, "B").start();

        new Thread(() -> {
           for (int i = 0; i < 10; i++) {
                data.increment();
            }
        }, "C").start();

        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                data.decrement();
            }
        }, "D").start();

    }
}

class Data {
    private int number = 0;

    public synchronized void increment() throws InterruptedException {
        while (number != 0) {
            //等待
            this.wait();
        }
        number++;
        System.out.println(Thread.currentThread().getName() + "==>" + number);
        //通知其他线程,我+1完毕了
        this.notifyAll();
    }

    public synchronized void decrement() throws InterruptedException {
        while (number == 0) {
            //等待
            this.wait();
        }
        number--;
        System.out.println(Thread.currentThread().getName() + "==>" + number);
        //通知其他线程,我-1完毕了
        this.notifyAll();
    }
}

Lock的最终版本:

public class PC {
    public static void main(String[] args) {
        Data data = new Data();
        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                data.decrement();
            }
        },"A").start();
        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                data.increment();
            }
        },"B").start();
        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                data.decrement();
            }
        },"C").start();
        new Thread(()->{
            for (int i = 0; i < 10; i++) {
                data.increment();
            }
        },"D").start();
    }
}
class Data {
    private int number = 0;
    Lock lock = new ReentrantLock();
    Condition condition = lock.newCondition();
    public void increment() {
        lock.lock();
        try {
            while (number > 0) {
                condition.await();
            }
            number++;
            System.out.println(Thread.currentThread().getName() + "=>" + number);
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }
    public void decrement() {
        lock.lock();
        try {
            while (number <= 0) {
                condition.await();
            }
            number--;
            System.out.println(Thread.currentThread().getName() + "=>" + number);
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }
}

3.3 Condition精准唤醒生产者消费者

使用了精准唤醒就不会出现虚假唤醒问题,也就不需要while了

(因为虚假唤醒就是不确定释放的锁给谁才出现的,现在有了精准唤醒就不会出现虚假唤醒问题)

使用Condition接口的特点,指定对象等待以及指定对象的唤醒

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ConditionTest {
    public static void main(String[] args) {
        ConditionTest01 conditionTest01 = new ConditionTest01();
        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                conditionTest01.method01();
            }
        },"AAAA").start();
        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                conditionTest01.method02();
            }
        },"BBBB").start();
        new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                conditionTest01.method03();
            }
        },"CCCC").start();
    }
}
class ConditionTest01  {
    Lock lock = new ReentrantLock();
    Condition condition1 = lock.newCondition();
    Condition condition2 = lock.newCondition();
    Condition condition3 = lock.newCondition();
    private int number = 2;	//作为一个标识符,用于判断是哪个线程休眠
    public void method01() {
        lock.lock();
        try {
			// 这里为了测试是否真的精准唤醒不会虚假唤醒而使用if  实际使用上就应该按上文说的用while
            if (number != 1){
                condition1.await();
            }
            number = 2;
            System.out.println(Thread.currentThread().getName()+"唤醒了condition2");
            condition2.signal();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
    public void method02() {
        lock.lock();
        try {
            if (number != 2){
                condition2.await();
            }
            number = 3;
            System.out.println(Thread.currentThread().getName()+"唤醒了condition3");
            condition3.signal();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
    public void method03() {
        lock.lock();
        try {
            if (number != 3){
                condition3.await();
            }
            number = 1;
            System.out.println(Thread.currentThread().getName()+"唤醒了condition1");
            condition1.signal();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }
}

4. ReadWriteLock读写锁

读写锁维护了一个读锁和一个写锁,这两个锁的机制是不同的。

  • 读锁:在没有任何线程占用写锁的情况下,同一时间可以有多个线程加读锁。
  • 写锁:在没有任何线程占用读锁的情况下,同一时间只能有一个线程加写锁。

4.0 基本使用

在业务中有读写操作的时候可以使用,不再只会用Lock锁

读写锁也可以分为:独占锁/排他锁(写锁)、共享锁(读锁)

image

读写锁在面对读和写操作时使用的锁是不一样的。

基本使用:

  1. 创建读写锁对象:ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
  2. 读操作的锁为:readWriteLock.writeLock().lock();readWriteLock.writeLock().unlock();
  3. 写操作的锁为:readWriteLock.readLock().lock();readWriteLock.readLock().unlock();

举例:模拟读和写两个操作

/**
 * 独占锁(写锁)一次只能被一个线程占有
 * 共享锁(读锁)多个线程可以同时占有
 * ReadWriteLock
 * 读-读 可以共存!
 * 读-写 不能共存!
 * 写-写 不能共存!
 * 
 */
public class ReadWriteLockDemo {
    public static void main(String[] args) {

        MyCacheLock myCache = new MyCacheLock();

        for (int i = 1; i <= 5; i++) {
            final String temp = String.valueOf(i);
            new Thread(() -> {
                myCache.put(temp, temp);
            }, temp).start();
        }

        for (int i = 1; i <= 5; i++) {
            final String temp = String.valueOf(i);
            new Thread(() -> {
                myCache.get(temp);
            }, temp).start();
        }

    }
}

//加了读写锁的读写操作,写的时候是A写完才能到B写,读的话可以一起进来读
class MyCacheLock {
    private volatile Map<String, Object> map = new HashMap<>();
    private ReadWriteLock readWriteLock = new ReentrantReadWriteLock();

    //存,写
    public void put(String key, Object value) {
        //上锁
        readWriteLock.writeLock().lock();
        try {
            System.out.println(Thread.currentThread().getName() + "写入" + key);
            map.put(key, value);
            System.out.println(Thread.currentThread().getName() + "写入ok");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //解锁(一般是放在try/catch中的finally部分保证会执行
            readWriteLock.writeLock().unlock();
        }
    }

    //取,读
    public void get(String key) {
        //上锁
        readWriteLock.readLock().lock();
        try {
            System.out.println(Thread.currentThread().getName() + "读取" + key);
            map.get(key);
            System.out.println(Thread.currentThread().getName() + "读取ok");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //解锁(一般是放在try/catch中的finally部分保证会执行
            readWriteLock.readLock().unlock();
        }
    }
}

//不加锁的读写操作:出问题,A写还没写完就被读了,并且中间B也进来写了可能会造成数据覆盖
class MyCache {
    private volatile Map<String, Object> map = new HashMap<>();

    //存,写
    public void put(String key, Object value) {
        System.out.println(Thread.currentThread().getName() + "写入" + key);
        map.put(key, value);
        System.out.println(Thread.currentThread().getName() + "写入ok");
    }

    //取,读
    public void get(String key) {
        System.out.println(Thread.currentThread().getName() + "读取" + key);
        map.get(key);
        System.out.println(Thread.currentThread().getName() + "读取ok");
    }
}

加了读写锁的结果:写的时候是A写完才能到B写,读的话可以一起进来读

1写入1
1写入ok
2写入2
2写入ok
3写入3
3写入ok
4写入4
4写入ok
5写入5
5写入ok
1读取
1读取ok
2读取
4读取
4读取ok
3读取
5读取
3读取ok
2读取ok
5读取ok

4.1 读锁工作方式

读优先锁期望的是,读锁能被更多的线程持有,以便提高读线程的并发性,它的工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 仍然可以成功获取读锁,最后直到读线程 A 和 C 释放读锁后,写线程 B 才可以成功获取读锁。如下图:

image


4.2 写锁工作方式

写优先锁是优先服务写线程,其工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 获取读锁时会失败,于是读线程 C 将被阻塞在获取读锁的操作,这样只要读线程 A 释放读锁后,写线程 B 就可以成功获取读锁。如下图:

image


4.3 产生的问题

如果一直有读线程获取读锁,那么写线程将永远获取不到写锁,这就造成了写线程「饥饿」的现象。

写优先锁可以保证写线程不会饿死,但是如果一直有写线程获取写锁,读线程也会被「饿死」。


解决方式:

既然不管优先读锁还是写锁,对方可能会出现饿死问题,那么我们就不偏袒任何一方,搞个「公平读写锁」。

公平读写锁比较简单的一种方式是:用队列把获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁即可,这样读线程仍然可以并发,也不会出现「饥饿」的现象。


4.4 锁升级和锁降级

A B是线程。下面的前提都是拿同一个对象锁

  1. A拿了写锁,则B读/写锁都不能拿。
  2. A拿了读锁,则B只能拿读锁,不能拿写锁
  3. A先拿了写锁再拿了读锁,则B读/写锁都不能拿,直到A释放写锁才能拿
  4. A先拿了读锁是不能再拿写锁的!(ReentrantReadWriteLock是不支持锁升级)

锁降级指的是:写锁降级为读锁。当一个线程持有写锁的情况下,虽然其他线程不能加读锁,但是线程自己是可以加读锁的:

public static void main(String[] args) throws InterruptedException {
    ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    lock.writeLock().lock();
    lock.readLock().lock();
    System.out.println("成功加读锁!");
}

那么,如果我们在同时加了写锁和读锁的情况下,释放写锁,是否其他的线程就可以一起加读锁了呢?

public static void main(String[] args) throws InterruptedException {
    ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    lock.writeLock().lock();
    lock.readLock().lock();
    new Thread(() -> {
        System.out.println("开始加读锁!");
        lock.readLock().lock();
        System.out.println("读锁添加成功!");
    }).start();
    TimeUnit.SECONDS.sleep(1);
    lock.writeLock().unlock();    //如果释放写锁,会怎么样?
}

可以看到,一旦写锁被释放,那么主线程就只剩下读锁了,因为读锁可以被多个线程共享,所以这时第二个线程也添加了读锁。而这种操作,就被称之为"锁降级"(注意不是先释放写锁再加读锁,而是持有写锁的情况下申请读锁再释放写锁)

注意在仅持有读锁的情况下去申请写锁,属于"锁升级",ReentrantReadWriteLock是不支持的:

public static void main(String[] args) throws InterruptedException {
    ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    lock.readLock().lock();
    lock.writeLock().lock();
    System.out.println("所升级成功!");
}

可以看到线程直接卡在加写锁的那一句了。


5. Callable

实现Callable接口是创建线程的方式之一,源码可知本Callable接口是一个函数式接口

image


5.1 介绍

callable的三个特点:

  1. 可以有返回值
  2. 可以抛出异常
  3. 方法不同,call()

下面这些api就是在描述一个图:Call也是Thread实现的,但它是通过伪装成Runnable进Thread的构造方法里。

如何伪装呢?Runable有一个实现类FutureTask的构造方法可以装入Callable,从而伪装成了Runnable。

image


jdk介绍:

image

Runnable的Api文档

image

FutureTask的Api文档

image

image


5.2 Callable基本使用

  1. 实现接口Callable,并实现call方法
  2. 创建Runnable实现类对象,并装入FutureTask构造方法内并创建FutureTask对象
  3. 将前面的FutureTask对象装入Thread构造方法内,然后启动线程start();
public class CallableTest {
    public static void main(String[] args) {
        MyThread thread = new MyThread();
        //Callable  ---   Runnable  中间转换(适配类)
        FutureTask<String> futureTask = new FutureTask<>(thread);
        //结果会被缓存,提高效率
        new Thread(futureTask, "A").start();
        new Thread(futureTask, "B").start();

        //获取返回值
        try {
            //这个get方法可能会产生阻塞,把它放到最后
            String s = futureTask.get();
            //或者使用异步通信来处理
            System.out.println(s);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }

    }
}

class MyThread implements Callable<String> {
    @Override
    public String call() {
        System.out.println("call()");
        //可能是耗时的操作
        return "123";
    }
}

输出:

产生疑问——为什么只输出了一次call?因为FutureTask有缓存!

注意:futureTask.get(); 可能会产生阻塞,所以要把它放到最后

call()
123

6. BlockingQueue阻塞队列

为线程池打基础

阻塞队列的原生:

image

阻塞队列的特点:

image

阻塞队列BlockingQueue是一个接口,它的实现类有:

① ArrayBlockingQueue(数组写的队列) ② LinkedBlockingQueue(链表写的队列) ③ SynchronousQueue同步队列


6.1 BlockingQueue四组API

ArrayBlockingQueue 和 LinkedBlockingQueue有以下四种API,但SynchronousQueue没有第一列,只有后面三列

API之间可以混合使用,只要代码逻辑没错即可

方式 有返回值,抛出异常 有返回值,不抛出异常 阻塞等待 超时等待
添加 add() offer() put() offer( , , )
移除 remove() poll() take() poll( , )
查看队列首元素 element() peek() - -
/**
 * 有返回值,抛出异常
 */
public static void test1() {
    ArrayBlockingQueue blockingQueue = new ArrayBlockingQueue(3);
    System.out.println(blockingQueue.add("a"));
    System.out.println(blockingQueue.add("b"));
    System.out.println(blockingQueue.add("c"));
    //抛出异常 java.lang.IllegalStateException: Queue full
    //System.out.println(blockingQueue.add("d"));
    //查看队列首元素
    System.out.println(blockingQueue.element());
    System.out.println("===================");
    System.out.println(blockingQueue.remove());
    System.out.println(blockingQueue.remove());
    System.out.println(blockingQueue.remove());
    //抛出异常 java.util.NoSuchElementException
    //System.out.println(blockingQueue.remove());
}

/**
* 有返回值,不抛出异常
*/
public static void test2() {
    ArrayBlockingQueue blockingQueue = new ArrayBlockingQueue(3);
    System.out.println(blockingQueue.offer("a"));
    System.out.println(blockingQueue.offer("b"));
    System.out.println(blockingQueue.offer("c"));
    //不抛出异常,返回false
    System.out.println(blockingQueue.offer("d"));
    //查看队列首元素
    System.out.println(blockingQueue.peek());
    System.out.println("===================");
    System.out.println(blockingQueue.poll());
    System.out.println(blockingQueue.poll());
    System.out.println(blockingQueue.poll());
    //不抛出异常,返回null
    System.out.println(blockingQueue.poll());
}

/**
* 阻塞等待,一直等
*/
public static void test3() {
    ArrayBlockingQueue blockingQueue = new ArrayBlockingQueue(3);
    try {
        blockingQueue.put("a");
        blockingQueue.put("b");
        blockingQueue.put("c");
        //队列没有位置了,一直阻塞
        //blockingQueue.put("d");

        System.out.println(blockingQueue.take());
        System.out.println(blockingQueue.take());
        System.out.println(blockingQueue.take());
        //队列没有元素了,一直阻塞
        //System.out.println(blockingQueue.take());
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

/**
* 超时等待
*/
public static void test4() {
    ArrayBlockingQueue blockingQueue = new ArrayBlockingQueue(3);
    try {
        //添加元素
        System.out.println(blockingQueue.offer("a", 2, TimeUnit.SECONDS));
        System.out.println(blockingQueue.offer("b", 2, TimeUnit.SECONDS));
        System.out.println(blockingQueue.offer("c", 2, TimeUnit.SECONDS));
        //尝试进去队列等待超过2秒进不去,则返回false
        System.out.println(blockingQueue.offer("d", 2, TimeUnit.SECONDS));
        System.out.println("================");

        //移除队尾元素
        System.out.println(blockingQueue.poll(2, TimeUnit.SECONDS));
        System.out.println(blockingQueue.poll(2, TimeUnit.SECONDS));
        System.out.println(blockingQueue.poll(2, TimeUnit.SECONDS));
        //从队列中取等待超过2秒没取到,则返回null
        System.out.println(blockingQueue.poll(2, TimeUnit.SECONDS));
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

6.2 SynchronousQueue同步队列

同步队列:是一个容量等于1的队列,进去一个元素,必须等待取出来之后,才能再往里面放一个元素!

举例:

public class SynchronousQueueDemo {
    public static void main(String[] args) {
        BlockingQueue<String> blockingQueue = new SynchronousQueue<>();

        new Thread(() -> {
            try {
                System.out.println(Thread.currentThread().getName() + " put a");
                blockingQueue.put("a");
                System.out.println(Thread.currentThread().getName() + " put b");
                blockingQueue.put("b");
                System.out.println(Thread.currentThread().getName() + " put c");
                blockingQueue.put("c");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        }, "T1").start();
        new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(2);
                System.out.println(Thread.currentThread().getName() + " ==> " + blockingQueue.take());
                TimeUnit.SECONDS.sleep(2);
                System.out.println(Thread.currentThread().getName() + " ==> " + blockingQueue.take());
                TimeUnit.SECONDS.sleep(2);
                System.out.println(Thread.currentThread().getName() + " ==> " + blockingQueue.take());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        }, "T2").start();

    }
}

输出

T1 put a
T2 ==> a
T1 put b
T2 ==> b
T1 put c
T2 ==> c

7. 线程池✨

7.1 介绍

之前使用的new Thread().start;来创建线程有问题:如果并发的线程数量很多,并且每个线程都是执行一个时间很短的任务就结束了,这样频繁创建线程就会大大降低系统的效率,因为频繁创建线程和销毁线程需要时间。所以就有了线程池的产生。

线程池(jdk1.5产生的)是:线程可以复用,就是执行完一个任务,并不被销毁,而是可以继续执行其他的任务

线程池的优点:

  1. 降低资源消耗。减少了创建和销毁线程的次数,每个工作线程都可以被重复利用,可执行多个任务。
  2. 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。
  3. 提高线程的可管理性。可以根据系统的承受能力,调整线程池中工作线线程的数目,防止因为消耗过多的内存,而把服务器累趴下(每个线程需要大约1MB内存,线程开的越多,消耗的内存也就越大,最后死机)。

7.2 线程池的简要工作模型

image
解释

  1. 线程池的工作模型主要两部分组成,一部分是运行Runnable的Thread对象,另一部分就是阻塞队列。
  2. 由线程池创建的Thread对象其内部的run方法会通过阻塞队列的take方法获取一个Runnable对象,然后执行这个Runnable对象的run方法
  3. 在Thread的run方法中调用Runnable对象的run方法
  4. 当Runnable对象的run方法执行完毕以后,Thread中的run方法又循环的从阻塞队列中获取下一个Runnable对象继续执行
  5. 这样就实现了Thread对象的重复利用,也就减少了创建线程和销毁线程所消耗的资源。

7.3 Executors类

7.3.0 基本操作

作用

在JDK1.5的时候java提供了线程池

java.util.concurrent.Executors类:线程池的工厂类,用来生产线程池

方法

  1. execute只能提交Runnable类型的任务,没有返回值,而submit既能提交Runnable类型任务也能提交Callable类型任务,返回Future类型。
  2. execute方法提交的任务异常是直接抛出的,而submit方法是是捕获了异常的,当调用FutureTask的get方法时,才会抛出异常。
static ExecutorService newFixedThreadPool(int nThreads)        //创建一个可重用固定线程数的线程池,以共享的无界队列方式来运行这些线程  int nThreads:创建线程池中线程的个数
submit(Runnable task)    //提交一个 Runnable 任务用于执行
execute(Runnable task)  //提交一个 Runnable 任务用于执行
oid shutdown()     //用于销毁线程池,一般不建议使用    //注意:线程池销毁之后,就在内存中消失了,就不能在执行线程任务了

使用步骤

阿里巴巴开发手册中讲:线程池不能用Executors类,因为会严重占用资源。一般是用 ThreadPoolExecutor,下面拒绝策略有 ThreadPoolExecutor版本的线程池。

image

  1. 使用线程池工厂类Executors提供的静态方法newFixedThreadPool生产一个指定线程数量的线程池
  2. 调用线程池ExecutorService中的方法submit,传递线程任务,执行线程任务

image

public static void main(String[] args) {
    //1.使用线程池工厂类Executors提供的静态方法newFixedThreadPool生产一个指定线程数量的线程池
    ExecutorService ex = Executors.newFixedThreadPool(2);
    //2.调用线程池ExecutorService中的方法submit,传递线程任务,执行线程任务
    //  相当于new Thread(new Runnable(){}).start();
    ex.submit(new Runnable() {
        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName()+"线程任务1执行了!");
        }
    });
    ex.submit(new Runnable() {
        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName()+"线程任务2执行了!");
        }
    });
    ex.shutdown();//销毁线程比
    ex.submit(new Runnable() { //会报错
        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName()+"线程任务3执行了!");
        }
    });
}

7.3.1 三大方法(创建对象方式

三大方法指的是三种创建线程对象的方法,以及他们创建出来的线程池的特点

  1. 第1大方法:单个线程ExecutorService threadExecutor = Executors.newSingleThreadExecutor();

  2. 第2大方法:创建一个固定的线程池大小ExecutorService threadExecutor = Executors.newFixedThreadPool(5);

  3. 第3大方法:可伸缩的,遇强则强,遇弱则弱(最大可以达到Integer.MAXOfValue) ExecutorService threadExecutor = Executors.newCachedThreadPool();

    本例中随着for增大而增大,但不是线性增长

public class Demo01 {
    public static void main(String[] args) {
        //第1大方法:单个线程
//        ExecutorService threadExecutor = Executors.newSingleThreadExecutor();
        //第2大方法:创建一个固定的线程池大小
//        ExecutorService threadExecutor = Executors.newFixedThreadPool(5);
        //第3大方法:可伸缩的,遇强则强,遇弱则弱(最大可以达到Integer.MAXOfValue)
        ExecutorService threadExecutor = Executors.newCachedThreadPool();

        try {
            for (int i = 0; i < 10; i++) {
                //使用了线程池之后,使用线程池来创建线程
                threadExecutor.execute(() -> {
                    System.out.println(Thread.currentThread().getName() + " ok");
                });
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //线程池用完,程序结束,关闭线程池
            threadExecutor.shutdown();
        }
    }
}

输出:

第1大方法

pool-1-thread-1 ok
pool-1-thread-1 ok
pool-1-thread-1 ok
pool-1-thread-1 ok
pool-1-thread-1 ok
pool-1-thread-1 ok
pool-1-thread-1 ok
pool-1-thread-1 ok
pool-1-thread-1 ok
pool-1-thread-1 ok

第2大方法

pool-1-thread-1 ok
pool-1-thread-5 ok
pool-1-thread-5 ok
pool-1-thread-5 ok
pool-1-thread-5 ok
pool-1-thread-3 ok
pool-1-thread-2 ok
pool-1-thread-5 ok
pool-1-thread-4 ok
pool-1-thread-1 ok

第3大方法

pool-1-thread-2 ok
pool-1-thread-1 ok
pool-1-thread-4 ok
pool-1-thread-3 ok
pool-1-thread-6 ok
pool-1-thread-5 ok
pool-1-thread-7 ok
pool-1-thread-9 ok
pool-1-thread-10 ok
pool-1-thread-8 ok

7.3.2 七大参数(构造函数参数

七大参数指的是 ThreadPoolExecutor 类的构造方法需要七个参数

Executors类构造方法源码:对Executors类三种构造方法源码分析可得都是通过一个类 ThreadPoolExecutor 来实现的。所以我们直接再去研究 ThreadPoolExecutor类的构造方法

public static ExecutorService newSingleThreadExecutor() {
    return new FinalizableDelegatedExecutorService
        (new ThreadPoolExecutor(1, 1,
                                0L, TimeUnit.MILLISECONDS,
                                new LinkedBlockingQueue<Runnable>()));
}


public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>());
}

public static ExecutorService newCachedThreadPool() {
    return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                  60L, TimeUnit.SECONDS,
                                  new SynchronousQueue<Runnable>());
}

ThreadPoolExecutor类构造方法源码:有七个参数

public ThreadPoolExecutor(int corePoolSize, //核心线程池大小
                          int maximumPoolSize, //最大小线程池大小
                          long keepAliveTime, //存活时间,超时了没有调用就会释放
                          TimeUnit unit,//超时单位
                          BlockingQueue<Runnable> workQueue,//阻塞队列
                          ThreadFactory threadFactory,//线程工厂,创建线程的,一般不用动(用默认)
                          RejectedExecutionHandler handler //拒绝策略(根据需求自行选择四种策略之一)
                         ) {
    if (corePoolSize < 0 ||
        maximumPoolSize <= 0 ||
        maximumPoolSize < corePoolSize ||
        keepAliveTime < 0)
        throw new IllegalArgumentException();
    if (workQueue == null || threadFactory == null || handler == null)
        throw new NullPointerException();
    this.acc = System.getSecurityManager() == null ?
        null :
    AccessController.getContext();
    this.corePoolSize = corePoolSize;
    this.maximumPoolSize = maximumPoolSize;
    this.workQueue = workQueue;
    this.keepAliveTime = unit.toNanos(keepAliveTime);
    this.threadFactory = threadFactory;
    this.handler = handler;
}

参数的讲解:客人是线程,旧窗口是核心线程池大小,新窗口+旧窗口是最大线程池大小,候客区是阻塞队列,拒绝客人的是拒绝策略。

客人来银行办理业务,当客人少于两人的时候就直接去旧窗口,若客人多了就先坐候客区,然后慢慢的客人越来越多,旧窗口和候客区都满了就开放右边三个新窗口给客人办理业务,客人又来了一大波,此时所有窗口和候客区满了就拒绝客人进来。

image


7.3.3 四种拒绝策略

四种拒绝策略

  1. 队列满了,线程数达到最大线程数,还有线程过来,不处理这个线程,抛出异常
    new ThreadPoolExecutor.AbortPolicy() 【默认】
  2. 直接让提交任务的线程运行这个任务,比如在主线程向线程池提交了任务,那么就直接由主线程执行。
    new ThreadPoolExecutor.CallerRunsPolicy()
  3. 队列满了,丢掉任务,不会抛出异常
    new ThreadPoolExecutor.DiscardPolicy()
  4. 队列满了,尝试和最早的竞争,竞争失败丢掉任务,也不会抛出异常
    new ThreadPoolExecutor.DiscardOldestPolicy()

image


使用 ThreadPoolExecutor创建线程(推荐)

public class Demo01 {
    public static void main(String[] args) {
        //自定义线程池
        ExecutorService threadExecutor = new ThreadPoolExecutor(
                2,
                5,
                3,
                TimeUnit.SECONDS,
                new LinkedBlockingDeque<>(3),
                Executors.defaultThreadFactory(),
            	//四种拒绝策略:
                //队列满了,线程数达到最大线程数,还有线程过来,不处理这个线程,抛出异常
//                new ThreadPoolExecutor.AbortPolicy()
                //哪里来的就去哪里
//                new ThreadPoolExecutor.CallerRunsPolicy()
                //队列满了,丢掉任务,不会抛出异常
//                new ThreadPoolExecutor.DiscardPolicy()
                //队列满了,尝试和最早的竞争,竞争失败丢掉任务,也不会抛出异常
                new ThreadPoolExecutor.DiscardOldestPolicy()
        );

        try {
            //最大承载:Deque + Max  超过,RejectedExecutionException
            for (int i = 0; i < 9; i++) {
                //使用了线程池之后,使用线程池来创建线程
                threadExecutor.execute(() -> {
                    System.out.println(Thread.currentThread().getName() + " ok");
                });
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //线程池用完,程序结束,关闭线程池
            threadExecutor.shutdown();
        }
    }
}

结果:

new ThreadPoolExecutor.AbortPolicy() 输出

pool-1-thread-2 ok
pool-1-thread-3 ok
pool-1-thread-1 ok
pool-1-thread-3 ok
pool-1-thread-2 ok
pool-1-thread-4 ok
pool-1-thread-5 ok
pool-1-thread-1 ok
java.util.concurrent.RejectedExecutionException: Task com.zyy.pool.Demo01$$Lambda$1/1096979270@7ba4f24f rejected from java.util.concurrent.ThreadPoolExecutor@3b9a45b3[Running, pool size = 5, active threads = 0, queued tasks = 0, completed tasks = 8]
	at java.util.concurrent.ThreadPoolExecutor$AbortPolicy.rejectedExecution(ThreadPoolExecutor.java:2063)
	at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:830)
	at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1379)
	at com.zyy.pool.Demo01.main(Demo01.java:40)

new ThreadPoolExecutor.CallerRunsPolicy() 输出

pool-1-thread-2 ok
main ok
pool-1-thread-4 ok
pool-1-thread-3 ok
pool-1-thread-1 ok
pool-1-thread-3 ok
pool-1-thread-4 ok
pool-1-thread-5 ok
pool-1-thread-2 ok

new ThreadPoolExecutor.DiscardPolicy() 输出

pool-1-thread-2 ok
pool-1-thread-1 ok
pool-1-thread-2 ok
pool-1-thread-1 ok
pool-1-thread-2 ok
pool-1-thread-3 ok
pool-1-thread-4 ok
pool-1-thread-5 ok

new ThreadPoolExecutor.DiscardOldestPolicy() 输出

pool-1-thread-2 ok
pool-1-thread-3 ok
pool-1-thread-4 ok
pool-1-thread-1 ok
pool-1-thread-4 ok
pool-1-thread-2 ok
pool-1-thread-3 ok
pool-1-thread-5 ok

7.3.4 拓展:最大线程数应该如何设置?

有两种设置方式:CPU密集型和IO密集型

  1. CPU密集型,几核,就是几,可以保证CPU效率最高

  2. IO密集型 (判断你程序中十分耗IO的线程)

    如程序中有15个大型任务,IO十分消耗资源,一般设置为2倍,为30


以CPU密集型为例:

获取CPU核数的方法

//获取CPU核数
System.out.println(Runtime.getRuntime().availableProcessors());

代码优化:用上面获取CPU核数的方法来替代最大线程数(这样可以避免不同性能的电脑都可以跑起来,并且保证性能最大化)

public class Demo01 {
    public static void main(String[] args) {
        //自定义线程池
        ExecutorService threadExecutor = new ThreadPoolExecutor(
                4,
                Runtime.getRuntime().availableProcessors(),
                3,
                TimeUnit.SECONDS,
                new LinkedBlockingDeque<>(3),
                Executors.defaultThreadFactory(),
                //队列满了,丢掉任务,不会抛出异常
                new ThreadPoolExecutor.DiscardPolicy()
        );

        try {
            //最大承载:Deque + Max  超过,RejectedExecutionException
            for (int i = 0; i < 10; i++) {
                //使用了线程池之后,使用线程池来创建线程
                threadExecutor.execute(() -> {
                    System.out.println(Thread.currentThread().getName() + " ok");
                });
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //线程池用完,程序结束,关闭线程池
            threadExecutor.shutdown();
        }
    }
}

8.并发工具类

8.1 CountDownLatch 计数器锁

也可以理解为减法计数器,因为countDown方法减一计数

image

基本使用:

  1. 创建CountDownLatch对象并指定计数数量
  2. 在每个线程的最后都要使用countDown方法减一计数
  3. 在main主线程内子线程后写await等待计数器归零(该代码是等待六个线程执行完),再继续执行await后的代码
public class CountDownLatchDemo {
    public static void main(String[] args) {
        //总数是6 必须要执行的任务的时候再使用
        CountDownLatch countDownLatch = new CountDownLatch(6);
        for (int i = 0; i < 6; i++) {
            new Thread(() -> {
                System.out.println(Thread.currentThread().getName()+" go out");
                // 数量-1
                countDownLatch.countDown();
            }).start();
        }

        try {
            //等待计数器归零,然后再往下执行
            countDownLatch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("close door");

    }
}

8.2 CyclicBarr 循环屏障

也可以理解为加法计数器

image

基本使用

  1. 创建CyclicBarrier对象并指定 目标线程数量,以及 达到目标线程数量后再执行的线程
  2. 在每个线程的最后都要使用await方法,让当前线程等待直到到达目标线程数量后,才允许线程继续执行await后的代码并执行构造方法里的线程
public class CyclicBarrierDemo {
    public static void main(String[] args) {
        /**
         * 集齐七颗龙珠,召唤神龙
         */
        CyclicBarrier cyclicBarrier = new CyclicBarrier(7, ()->{
            System.out.println("召唤神龙成功!");
        });
        for (int i = 1; i <= 7; i++) {
            final int temp = i;
            new Thread(() -> {
                System.out.println("收集"+temp+"星龙珠");
                try {
                    //阻塞
                    cyclicBarrier.await();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } catch (BrokenBarrierException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

输出

收集1星龙珠
收集2星龙珠
收集3星龙珠
收集5星龙珠
收集4星龙珠
收集6星龙珠
收集7星龙珠
召唤神龙成功!

加/减法计数器的区别:

  • 减法是卡线程后await后的代码;
  • 而加法卡的是线程内await后的代码;

8.3 Semaphore信号量

信号量(Semaphore),有时被称为信号灯,是在多线程环境下使用的一种设施,是可以用来保证两个或多个关键代码段不被并发调用。在进入一个关键代码段之前,线程必须获取一个信号量;一旦该关键代码段完成了,那么该线程必须释放信号量。其它想进入该关键代码段的线程必须等待直到第一个线程释放信号量。

作用:多个共享资源互斥使用!并发限流,控制最大线程数!

image

基本使用:

semaphore.acquire(); //获得,假设已经满了,等待,等待被释放为止!

semaphore.release();//释放,会将当前的信号量释放,然后唤醒等待线程!


举例:抢车位 5辆车,3个停车位

public class SemaphoreDemo {
    public static void main(String[] args) {
        //3个停车位 限流
        Semaphore semaphore = new Semaphore(3);//限流数
        for (int i = 1; i <= 5; i++) {
            new Thread(() -> {
                try {
                    //获得
                    semaphore.acquire();
                    System.out.println(Thread.currentThread().getName() + "获得停车位");
                    TimeUnit.SECONDS.sleep(2);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    //释放
                    semaphore.release();
                    System.out.println(Thread.currentThread().getName() + "离开停车位");
                }
            }, String.valueOf(i)).start();
        }
    }
}

输出:

2获得停车位
1获得停车位
3获得停车位
2离开停车位
5获得停车位
3离开停车位
4获得停车位
1离开停车位
4离开停车位
5离开停车位

8.4 Exchanger 数据交换

线程之间的数据传递也可以这么简单。

使用Exchanger,它能够实现线程之间的数据交换:

public static void main(String[] args) throws InterruptedException {
    Exchanger<String> exchanger = new Exchanger<>();
    new Thread(() -> {
        try {
            System.out.println("收到主线程传递的交换数据:"+exchanger.exchange("AAAA"));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start();
    System.out.println("收到子线程传递的交换数据:"+exchanger.exchange("BBBB"));
}

在调用exchange方法后,当前线程会等待其他线程调用同一个exchanger对象的exchange方法,当另一个线程也调用之后,方法会返回对方线程传入的参数。


8.5 ForkJoin分支合并

8.5.1 Fork/Join 框架介绍

在JDK7时,出现了一个新 的框架用于并行执行任务,它的目的是为了把大型任务拆分为多个小任务,最后汇总多个小任务的结果,得到整大任务的结果,并且这些小任务都是同时在进行,大大提高运算效率。Fork就是拆分,Join就是合并。

我们来演示一下实际的情况,比如一个算式:18x7+36x8+9x77+8x53,可以拆分为四个小任务:18x7、36x8、9x77、8x53,最后我们只需要将这四个任务的结果加起来,就是我们原本算式的结果了,有点归并排序的味道。

image

它不仅仅只是拆分任务并使用多线程,而且还可以利用工作窃取算法,提高线程的利用率。

工作窃取算法:是指某个线程从其他队列里窃取任务来执行。一个大任务分割为若干个互不依赖的子任务,为了减少线程间的竞争,把这些子任务分别放到不同的队列里,并为每个队列创建一个单独的线程来执行队列里的任务,线程和队列一一对应。但是有的线程会先把自己队列里的任务干完,而其他线程对应的队列里还有任务待处理。干完活的线程与其等着,不如帮其他线程干活,于是它就去其他线程的队列里窃取一个任务来执行。

image


8.5.2 Fork/Join的使用:

ForkJoin分支合并是通过ForkJoinPool接口实现的。

ForkJoinPool接口的实现类有两种:RecursiveAction没有返回值、RecursiveTask有返回值

image

image

使用步骤:(以RecursiveTask为例)

步骤:

  1. 计算类继承RecursiveTask

  2. 计算类重写compute方法

  3. main函数中使用ForkJoinPool类对象调用方法forkJoinPool.execute(ForkJoinTask<?> task)【括号内是继承了RecursiveTask的计算类对象】,开启ForkJoin


举例:这里以计算1-1000的和为例,我们可以将其拆分为8个小段的数相加,比如1-125、126-250... ,最后再汇总即可,它也是依靠线程池来实现的:

public class Main {
    public static void main(String[] args) throws InterruptedException, ExecutionException {
        ForkJoinPool pool = new ForkJoinPool();
        SubTask subtask = new SubTask(1, 1000);
        System.out.println(pool.submit(subtask).get());//get方法获取结果
    }


  	//继承RecursiveTask,这样才可以作为一个任务,泛型就是计算结果类型
    private static class SubTask extends RecursiveTask<Integer> {
        private final int start;   //比如我们要计算一个范围内所有数的和,那么就需要限定一下范围,这里用了两个int存放
        private final int end;

        public SubTask(int start, int end) {
            this.start = start;
            this.end = end;
        }

        @Override
        protected Integer compute() {
            if(end - start > 125) {    //每个任务最多计算125个数的和,如果大于继续拆分,小于就可以开始算了
                SubTask subTask1 = new SubTask(start, (end + start) / 2);
                subTask1.fork();    //会继续划分子任务执行
                SubTask subTask2 = new SubTask((end + start) / 2 + 1, end);
                subTask2.fork();   //会继续划分子任务执行
                return subTask1.join() + subTask2.join();   //返回计算结果给上一级(越玩越有递归那味了)
            } else {
                System.out.println(Thread.currentThread().getName()+" 开始计算 "+start+"-"+end+" 的值!");
                int res = 0;
                for (int i = start; i <= end; i++) {
                    res += i;
                }
                return res;   //返回的结果会作为join的结果
            }
        }
    }
}
ForkJoinPool-1-worker-2 开始计算 1-125 的值!
ForkJoinPool-1-worker-2 开始计算 126-250 的值!
ForkJoinPool-1-worker-0 开始计算 376-500 的值!
ForkJoinPool-1-worker-6 开始计算 751-875 的值!
ForkJoinPool-1-worker-3 开始计算 626-750 的值!
ForkJoinPool-1-worker-5 开始计算 501-625 的值!
ForkJoinPool-1-worker-4 开始计算 251-375 的值!
ForkJoinPool-1-worker-7 开始计算 876-1000 的值!
500500

可以看到,结果非常正确,但是整个计算任务实际上是拆分为了8个子任务同时完成的,结合多线程,原本的单线程任务,在多线程的加持下速度成倍提升。


拓展:

  1. 包括Arrays工具类提供的并行排序也是利用了ForkJoinPool来实现:并行排序的性能在多核心CPU环境下,肯定是优于普通排序的,并且排序规模越大优势越显著。
public static void parallelSort(byte[] a) {
    int n = a.length, p, g;
    if (n <= MIN_ARRAY_SORT_GRAN ||
        (p = ForkJoinPool.getCommonPoolParallelism()) == 1)
        DualPivotQuicksort.sort(a, 0, n - 1);
    else
        new ArraysParallelSortHelpers.FJByte.Sorter
            (null, a, new byte[n], 0, n, 0,
             ((g = n / (p << 2)) <= MIN_ARRAY_SORT_GRAN) ?
             MIN_ARRAY_SORT_GRAN : g).invoke();
}

  1. 三种计算方式的效率对比(Stream并行流最好)
public class Test {

    public static void main(String[] args) {
        //耗时:6295
//        test1();
        //耗时:4401
//        test2();
        //耗时:294
        test3();
    }

    //普通方式
    public static void test1() {
        Long sum = 0L;
        long start = System.currentTimeMillis();
        for (Long i = 1L; i <= 10_0000_0000L; i++) {
            sum += i;
        }
        long end = System.currentTimeMillis();
        System.out.println("sum:" + sum + " 时间:" + (end - start));
    }

    public static void test2() {
        long start = System.currentTimeMillis();
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        ForkJoinTask<Long> forkJoinTask = new ForkJoinDemo(0L, 10_0000_0000L);
        ForkJoinTask<Long> submit = forkJoinPool.submit(forkJoinTask);
        Long sum = null;
        try {
            sum = submit.get();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
        long end = System.currentTimeMillis();
        System.out.println("sum:" + sum + " 时间:" + (end - start));
    }

    public static void test3() {
        long start = System.currentTimeMillis();
        //Stream 并行流
        long sum = LongStream.range(0L, 10_0000_0001L).parallel().reduce(0, Long::sum);
        long end = System.currentTimeMillis();
        System.out.println("sum:" + sum + " 时间:" + (end - start));
    }
}

9. 异步回调

同步:指等待资源(阻塞)

异步:指设立哨兵,资源空闲通知线程,否则该线程去做其他事情(非阻塞)


9.1 CompletableFuture

CompletableFuture 在 Java 里面被用于异步编程,异步通常意味着非阻塞,可以使得我们的任务单独运行在与主线程分离的其他线程中,并且通过回调可以在主线程中得到异步任务的执行状态,是否完成,和是否异常等信息

image

CompletableFuture 实现了 Future, CompletionStage 接口,实现了 Future接口就可以兼容现在有线程池框架,而 CompletionStage 接口才是异步编程的接口抽象,里面定义多种异步方法,通过这两者集合,从而打造出了强大的CompletableFuture 类:

  • 异步调用没有返回值方法runAsync
  • 异步调用有返回值方法supplyAsync

主线程调用 get 方法会阻塞

public class CompletableFutureTest {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // 异步调用没有返回值
        CompletableFuture<Void> completableFuture = CompletableFuture.runAsync(()->{
            System.out.println(Thread.currentThread().getName()+" : CompletableFuture");
        });
        completableFuture.get();

        // 异步调用
        // mq消息队列
        CompletableFuture<Integer> completableFuture1 = CompletableFuture.supplyAsync(()->{
            System.out.println(Thread.currentThread().getName()+" : CompletableFuture1");
            // 模拟异常
            int i = 10/0;
            return 1024;
        });
        // 完成之后调用得返回值
        completableFuture1.whenComplete((Integer t, Throwable u)->{
            System.out.println("-----t:"+t);    // 方法的返回值
            System.out.println("-----u:"+u);    // 异常的返回信息
        }).get();
    }
}

具体whenComplete的源代码为:

t为返回结果,u为异常信息

public CompletableFuture<T> whenComplete(
    BiConsumer<? super T, ? super Throwable> action) {
    return uniWhenCompleteStage(null, action);
}

9.2 Future 与 CompletableFutured对比

Future是Callable那章的内容

对比这两种方法,一个为同步一个为异步

Futrue 在 Java 里面,通常用来表示一个异步任务的引用,比如我们将任务提交到线程池里面,然后我们会得到一个 Futrue,在 Future 里面有 isDone 方法来 判断任务是否处理结束,还有 get 方法可以一直阻塞直到任务结束然后获取结果,但整体来说这种方式,还是同步的,因为需要客户端不断阻塞等待或者不断轮询才能知道任务是否完成

  1. 不支持手动完成: 我提交了一个任务,但是执行太慢了,我通过其他路径已经获取到了任务结果,现在没法把这个任务结果通知到正在执行的线程,所以必须主动取消或者一直等待它执行完成
  2. 不支持进一步的非阻塞调用: 通过 Future 的 get 方法会一直阻塞到任务完成,但是想在获取任务之后执行额外的任务,因为 Future不支持回调函数,所以无法实现这个功能
  3. 不支持链式调用: 对于 Future 的执行结果,我们想继续传到下一个 Future 处理使用,从而形成一个链式的 pipline 调用,这在 Future 中是没法实现的。
  4. 不支持多个 Future 合并: 比如我们有 10 个 Future 并行执行,我们想在所有的 Future 运行完毕之后,执行某些函数,是没法通过 Future 实现的。
  5. 不支持异常处理:Future 的 API 没有任何的异常处理的 api,所以在异步运行时,如果出了问题是不好定位的
    的。
  6. 不支持异常处理:Future 的 API 没有任何的异常处理的 api,所以在异步运行时,如果出了问题是不好定位的

10. Volatile

10.1 JMM

推荐好文:https://baijiahao.baidu.com/s?id=1709086005694976168&wfr=spider&for=pc

10.1.1 JMM是什么

JMM(Java Memory Model),Java的内存模型。不存在的东西,概念!约定!


10.1.2 JMM的作用

缓存一致性的协议,用来定义数据读写的规则。

JMM定义了线程工作内存和主内存的抽象关系:线程的共享变量存储在主内存中,每个线程都有一个私有的本地工作内存。

使用volatile关键字来解决共享变量的可见性的问题。

Java内存模型是围绕着并发编程中原子性、可见性、有序性这三个特征来建立的。


10.1.3 JMM的操作

image

主内存:对应堆中存放对象的实例的部分。

工作内存:对应线程的虚拟机栈的部分区域,虚拟机可能会对这部分内存进行优化,将其放在CPU的寄存器或是高速缓存中。比如在访问数组时,由于数组是一段连续的内存空间,所以可以将一部分连续空间放入到CPU高速缓存中,那么之后如果我们顺序读取这个数组,那么大概率会直接缓存命中。

JMM定义了8种操作来完成(每一种操作都是原子的、不可再拆分的)

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎(每当虚拟机遇到一个需要使用到该变量的值的字节码指令时将会执行这个操作)。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量(每当虚拟机遇到一个给该变量赋值的字节码指令时执行这个操作)。
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

10.1.4 JMM定义的规则

关于JMM的一些同步的约定

  1. 线程解锁前,必须把共享变量即可刷回主存
  2. 线程加锁前,必须 读取主存中的最新值到工作内存中
  3. 加锁和解锁是同一把锁

8种操作必须满足的规则:

  • 不允许read和load、store和write操作之一单独出现。(不允许一个变量从主内存读取了但工作内存不接受;或者从工作内存发起回写了但主内存不接受的情况出现)
  • 不允许一个线程丢弃它的最近的assign操作。(变量在工作内存中改变了值之后,必须把该变化同步回主内存)
  • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存。
  • 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。(就是对一个变量实施use、store操作之前,必须先执行过了load和assign操作)
  • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
  • 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值。
  • 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量。
  • 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。

10.1.5 happens-before先行发生原则

经过我们前面的讲解,相信各位已经了解了JMM内存模型以及重排序等机制带来的优点和缺点,综上,JMM提出了happens-before(先行发生)原则,定义一些禁止编译优化的场景,来向各位程序员做一些保证,只要我们是按照原则进行编程,那么就能够保持并发编程的正确性。具体如下:

  • 程序次序规则:同一个线程中,按照程序的顺序,前面的操作happens-before后续的任何操作。
    • 同一个线程内,代码的执行结果是有序的。其实就是,可能会发生指令重排,但是保证代码的执行结果一定是和按照顺序执行得到的一致,程序前面对某一个变量的修改一定对后续操作可见的,不可能会出现前面才把a修改为1,接着读a居然是修改前的结果,这也是程序运行最基本的要求。
  • 监视器锁规则:对一个锁的解锁操作,happens-before后续对这个锁的加锁操作。
    • 就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果。比如前一个线程将变量x的值修改为了12并解锁,之后另一个线程拿到了这把锁,对之前线程的操作是可见的,可以得到x是前一个线程修改后的结果12(所以synchronized是有happens-before规则的)
  • volatile变量规则:对一个volatile变量的写操作happens-before后续对这个变量的读操作。
    • 就是如果一个线程先去写一个volatile变量,紧接着另一个线程去读这个变量,那么这个写操作的结果一定对读的这个变量的线程可见。
  • 线程启动规则:主线程A启动线程B,线程B中可以看到主线程启动B之前的操作。
    • 在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。
  • 线程加入规则:如果线程A执行操作join()线程B并成功返回,那么线程B中的任意操作happens-before线程Ajoin()操作成功返回。
  • 传递性规则:如果A happens-before B,B happens-before C,那么A happens-before C。

那么我们来从happens-before原则的角度,来解释一下下面的程序结果:

public class Main {
    private static int a = 0;
  	private static int b = 0;
    public static void main(String[] args) {
        a = 10;
        b = a + 1;
        new Thread(() -> {
          if(b > 10) System.out.println(a); 
        }).start();
    }
}

首先我们定义以上出现的操作:

  • A:将变量a的值修改为10
  • B:将变量b的值修改为a + 1
  • C:主线程启动了一个新的线程,并在新的线程中获取b,进行判断,如果为true那么就打印a

首先我们来分析,由于是同一个线程,并且B是一个赋值操作且读取了A,那么按照程序次序规则,A happens-before B,接着在B之后,马上执行了C,按照线程启动规则,在新的线程启动之前,当前线程之前的所有操作对新的线程是可见的,所以 B happens-before C,最后根据传递性规则,由于A happens-before B,B happens-before C,所以A happens-before C,因此在新的线程中会输出a修改后的结果10


10.2 Volatile三大特性

前置知识:

原子性:一个或多个程序指令,要么全部正确执行完毕不能被打断,或者全部不执行。

可见性:当一个线程修改了某个共享变量的值,其它线程应当能够立即看到修改后的值。

有序性:程序执行代码指令的顺序应当保证按照程序指定的顺序执行,即便是编译优化,也应当保证程序源语一致。

Volatile是java虚拟机提供轻量级的同步机制,有如下特性:

  1. 保证可见性
  2. 不保证原子性
  3. 保证有序性:禁止指令重排

10.2.1 保证可见性

保证可见性验证:

public class JMMDemo {
    //加了volatile保证可见性
    private volatile static int number = 0;


    public static void main(String[] args) {
        //main

        new Thread(() -> {
            //子线程
            while (number == 0) {

            }

        }).start();
        try {
            TimeUnit.SECONDS.sleep(2);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        //我们想number = 1,之后子线程会停止循环,然而结果是子程序的循环并没有停止
        //这里有个问题就是主内存的值已经被修改了,但子线程没检测到值被修改
        number = 1;
        System.out.println("number "+number);
    }
}

10.2.2 不保证原子性

1)问题

不保证原子性验证:

线程A在执行任务的时候,不能被打扰的,也不能被分割,要么同时成功,要么同时失败。

/**
 * 不保证原子性
 */
public class VDemo02 {
    //这里加了volatile是不能保证原子性的
    private volatile static int number = 0;


    public static void add() {
        //不是一个原子性操作
        number ++;
    }

    public static void main(String[] args) {

        //理论上num结果应该为2万
        for (int i = 1; i <= 20; i++) {
            new Thread(()->{
                for (int j = 0; j < 1000; j++) {
                    add();
                }
            }).start();
        }
		
        //条件:当前线程大于2的时候主线程就礼让,出该循环时子线程一定是都完成了,只剩下主线程和gc线程在了
        while (Thread.activeCount() > 2) {
            //main gc
            Thread.yield();
        }

        System.out.println(Thread.currentThread().getName()+"-->"+number);

    }
}

输出(每次结果不固定)

main-->18795


用javap得到编译后的执行文件可以看出,++操作被拆成了四部,在这四步中就可能出现++失败(可能是++值被覆盖),不能保证原子性


image


2)解决方案

可以使用lock和synchronized锁来保证原子性,但如果不加lock和synchronized(因为同步锁性能差),怎么样保证原子性?

使用原子类,解决原子性问题

image

public class VDemo02 {
    //这里加了volatile是不能保证原子性的
    private volatile static AtomicInteger number = new AtomicInteger(0);


    public static void add() {
        //不是一个原子性的操作
//        number ++;
        //这里进源码看就是一个自旋锁CAS
        number.getAndIncrement();
    }


    public static void main(String[] args) {

        //理论上num结果应该为2万
        for (int i = 1; i <= 20; i++) {
            new Thread(()->{
                for (int j = 0; j < 1000; j++) {
                    add();
                }
            }).start();
        }

        while (Thread.activeCount() > 2) {
            //main gc
            Thread.yield();
        }

        System.out.println(Thread.currentThread().getName()+"-->"+number);

    }
}

3)拓展

  1. 原子类为啥可以做到原子性?

    进入到Unsafe类中会发现大都方法都被native修饰:这些类的底层都直接和操作系统挂钩,在内存中修改值!Unsafe是一个很特殊的存在

    看增加方法可知number.getAndIncrement();源码可知:是一个CAS自旋锁【具体的看下面CAS】

image


10.2.3 保证有序性:避免指令重排

保证有序性是通过避免指令重排来实现的


1)什么是指令重排?

-- 你写的程序,计算机并不是按照你写那样去执行的。

代码到执行经过:源代码 --》 编译器优化的重排 --》指令并行也可能会重排 --》内存系统也会重排 --》执行


处理器在进行指令重排的时候,是根据数据之间的依赖性来重排的。

如:

int x = 1;// 1
int y = 1;// 2
x = x + 5;// 3
y = x * x;// 4

//我们所期望的执行顺序:1234  但是可能执行的时候会变成2134 1324
//不可能是 4123,因为第四行代码依赖于第一行代码,第三行代码依赖于第一行代码

2)指令重排可能造成影响的结果

a b x y 这四个值默认值都是0

线程A 线程B
x=a y=b
b=1 a=2

正常的结果:x=0;y=0;


此时由于指令重排线程A执行顺序更改,线程B执行顺序更改

线程A 线程B
b=1 a=2
x=a y=b

重排导致的异常结果:x=2;y=1;


3)volatile避免指令重排

volatile使用内存屏障(是cpu指令)作用:

  1. 保证特定的操作的执行顺序
  2. 可以保证某些变量的内存可见性(利用这些特性volatile实现了可见性)

image

volatile可以保证可见性,不能保证原子性,由于内存屏障,可以保证避免指令重排的现象产生!


11. CAS

11.1 原理

CAS(Compare-and-Swap),即比较并替换,是一种实现并发算法时常用到的技术,Java并发包中的很多类都使用了CAS技术。


问题引入:前面volatile不保证原子性的问题例子

解决方案:前面volatile保证原子性的解决方案和拓展中使用到了CAS

看完解决方案可以得出:

CAS的思想:比较并替换。CAS需要有3个操作数:内存地址V,旧的预期值A,即将要更新的目标值B。

CAS指令执行时,当且仅当内存地址V的值与预期值A相等时,将内存地址V的值修改为B,否则就什么都不做。整个比较并替换的操作是一个原子操作。


compareAndSwapInt 是Unsafe类的方法,Unsafe是CAS核心类,由于java方法无法访问底层系统,需要通过本地(native)方法来访问,Unsafe相当于一个后门,基于该类可以直接操作特定内存的数据。Unsafe类存在于sun.misc包中,其内部方法操作可以像C的指针一样直接操作内存,因为java中CAS操作的执行依赖于Unsafe类的方法。

注意Unsafe类中所有方法都是native修饰的,也就是说Unsafe类中的方法都直接调用操作系统底层资源执行相应任务。

CAS并发原语体现在JAVA语言中就是sun.misc.Unsafe类中的各个方法。调用Unsafe中的CAS方法,JVM会帮我们实现出CAS汇编指令

这是一种完全依赖于硬件的功能,通过它实现了原子操作。再次强调,由于CAS是一种系统原语,原语属于操作系统用语范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成所谓的数据不一致性问题


拓展:

  1. CP如何实现原子操作?

    CPU 处理器速度远远大于在主内存中的,为了解决速度差异,在他们之间架设了多级缓存,如 L1、L2、L3 级别的缓存,这些缓存离CPU越近就越快,将频繁操作的数据缓存到这里,加快访问速度

    现在都是多核 CPU 处理器,每个 CPU 处理器内维护了一块字节的内存,每个内核内部维护着一块字节的缓存,当多线程并发读写时,就会出现缓存数据不一致的情况。

    此时,处理器提供:

    • 总线锁定

      当一个处理器要操作共享变量时,在 BUS 总线上发出一个 Lock 信号,其他处理就无法操作这个共享变量了。

      缺点很明显,总线锁定在阻塞其它处理器获取该共享变量的操作请求时,也可能会导致大量阻塞,从而增加系统的性能开销。

    • 缓存锁定

      后来的处理器都提供了缓存锁定机制,也就说当某个处理器对缓存中的共享变量进行了操作,其他处理器会有个嗅探机制,将其他处理器的该共享变量的缓存失效,待其他线程读取时会重新从主内存中读取最新的数据,基于 MESI 缓存一致性协议来实现的。现代的处理器基本都支持和使用的缓存锁定机制。

    注意:

    有如下两种情况处理器不会使用缓存锁定:

    (1)当操作的数据跨多个缓存行,或没被缓存在处理器内部,则处理器会使用总线锁定。

    (2)有些处理器不支持缓存锁定,比如:Intel 486 和 Pentium 处理器也会调用总线锁定。


11.2 CAS存在的三大问题

  1. 循环时间长开销很大。
  2. 只能保证一个变量的原子操作。
  3. ABA问题。

11.2.1 循环时间长开销很大

CAS 通常是配合无限循环一起使用的,我们可以看到 getAndAddInt 方法执行时,如果 CAS 失败,会一直进行尝试。如果 CAS 长时间一直不成功,可能会给 CPU 带来很大的开销。


11.2.2 只能保证一个变量的原子操作

当对一个变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个变量操作时,CAS 目前无法直接保证操作的原子性。但是我们可以通过以下两种办法来解决:1)使用互斥锁来保证原子性;2)将多个变量封装成对象,通过 AtomicReference 来保证原子性。


11.2.3 ABA问题

1)什么是ABA问题

ABA问题(狸猫换太子):线程A和B去操作同一变量a,在主存中a=0,线程A是要更改a为2,线程B是将a改成1又将a改回0。若线程B执行完了,执行A还在执行,此时线程A比对后就会发现a是0继续的更改为2。这里线程A没有发现此时的a=0不是最开始的0了,这就是ABA问题。

image

public class SACDemo {
    public static void main(String[] args) {
        AtomicInteger atomicInteger = new AtomicInteger(2020);
        //======捣乱的线程=========
        System.out.println(atomicInteger.compareAndSet(2020, 2021));
        System.out.println(atomicInteger.get());
        System.out.println(atomicInteger.compareAndSet(2021, 2020));
        System.out.println(atomicInteger.get());
        //======期望的线程=========
        System.out.println(atomicInteger.compareAndSet(2020, 1993));
        System.out.println(atomicInteger.get());

    }
}

2)解决ABA问题

解决ABA问题,引入原子引用!对应的思想:乐观锁(带版本号的原子操作!)

注意

Integer使用了对象缓存机制,默认范围是-128~127,推荐使用静态工厂方法valueOf获取对象实例,而不是new,因为valueOf使用缓存,而new一定会创建新的对象分配新的内存空间。【阿里巴巴手册👇】
image

public class SACDemo {
    public static void main(String[] args) {
        Integer int_1993 = 1993;
        Integer int_2020 = 2020;
        Integer int_2021 = 2021;
        //AtomicStampedReference<Integer> 注意,如果泛型是一个包装类,注意对象的引用问题
        AtomicStampedReference<Integer> reference = new AtomicStampedReference<>(int_2020, 1);

        new Thread(() -> {
         //reference.getStamp()是版本号
            System.out.println("A1->" + reference.getStamp());
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("A 2020->2021 " + reference.compareAndSet(int_2020, int_2021, reference.getStamp(), reference.getStamp() + 1));
            System.out.println("A2->" + reference.getStamp());
            System.out.println("A 2021->2020 " + reference.compareAndSet(int_2021, int_2020, reference.getStamp(), reference.getStamp() + 1));
            System.out.println("A3->" + reference.getStamp());
        }, "A").start();

        new Thread(() -> {
            System.out.println("B1->" + reference.getStamp());
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("B 2020->1993 " + reference.compareAndSet(int_2020, int_1993, reference.getStamp(), reference.getStamp() + 1));
            System.out.println("B2->" + reference.getStamp());

        }, "B").start();

    }
}

输出

A1->1
B1->1
A 2020->2021 true
A2->2
B 2020->1993 false
B2->3
A 2021->2020 true
A3->3

12. 锁的集合

12.0 各大锁的概念

共享锁(S锁):又称为读锁

排它锁/独占锁(X锁):又称为写锁(互斥锁也是一种独占锁)

悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。再比如Java里面的同步原语synchronized关键字的实现也是悲观锁。

乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。


12.1 公平锁与非公平锁

12.1.1 介绍

  • 公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。

    注意:在高并发的情况下公平锁不一样是公平的!(原因是AQS原理,但本篇没讲AQS)

  • 非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。

简单来说,公平锁不让插队,都老老实实排着;非公平锁让插队,但是排队的人让不让你插队就是另一回事了。

ReentrantLock的无参构造方法中,是这样写的:【默认是非公平的】

public ReentrantLock() {
    sync = new NonfairSync();   //看名字貌似是非公平的
}

有参构造方法:

public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

12.1.2 验证公平锁

这里我们选择使用第二个构造方法,可以选择是否为公平锁实现:

public static void main(String[] args) throws InterruptedException {
    ReentrantLock lock = new ReentrantLock(false);

    Runnable action = () -> {
        System.out.println("线程 "+Thread.currentThread().getName()+" 开始获取锁...");
        lock.lock();
        System.out.println("线程 "+Thread.currentThread().getName()+" 成功获取锁!");
        lock.unlock();
    };
    for (int i = 0; i < 10; i++) {   //建立10个线程
        new Thread(action, "T"+i).start();
    }
}

这里我们只需要对比将在1秒后开始获取锁...成功获取锁!的顺序是否一致即可,如果是一致,那说明所有的线程都是按顺序排队获取的锁,如果不是,那说明肯定是有线程插队了。

结果:

线程 T0 开始获取锁...
线程 T3 开始获取锁...
线程 T2 开始获取锁...
线程 T1 开始获取锁...
线程 T7 开始获取锁...
线程 T6 开始获取锁...
线程 T5 开始获取锁...
线程 T0 成功获取锁!
线程 T4 开始获取锁...
线程 T9 开始获取锁...
线程 T8 开始获取锁...
线程 T4 成功获取锁!
线程 T3 成功获取锁!
线程 T2 成功获取锁!
线程 T1 成功获取锁!
线程 T7 成功获取锁!
线程 T6 成功获取锁!
线程 T5 成功获取锁!
线程 T9 成功获取锁!
线程 T8 成功获取锁!

运行结果可以发现,在公平模式下,确实是按照顺序进行的,而在非公平模式下,一般会出现这种情况:线程刚开始获取锁马上就能抢到,并且此时之前早就开始的线程还在等待状态,很明显的插队行为。


12.1 可重入锁

可重入性(递归锁):就是一个线程不用释放,可以重复的获取一个锁n次,只是在释放的时候,也需要相应的释放n次。(简单来说:A线程在某上下文中获得了某锁,当A线程想要在次获取该锁时,不会应为锁已经被自己占用,而需要先等到锁的释放)假使A线程即获得了锁,又在等待锁的释放,就会造成死锁。


  1. 同一个线程可以对同一个对象连续拿锁,但记住最后一定要遵守锁了几次就一定要解锁几次!(若未unlock则其他需要锁的线程永远无法启动)

  2. 但不同线程对同一个对象拿锁就无法连续拿锁。必须等别的线程释放了该对象锁才能拿到锁

image

Synchronized和reentrantLock都是可重入锁


12.1.1 Synchronized版

两个Synchronized锁的对象是phone实例对象

public class Demo01 {
    public static void main(String[] args) {
        Phone phone = new Phone();
        new Thread(() -> {
            phone.sms();
        }, "A").start();

        new Thread(() -> {
            phone.sms();
        }, "B").start();
    }

}

class Phone {
    public synchronized void sms() {
        System.out.println(Thread.currentThread().getName() + " sms");
        //这里也有锁
        call();

    }

    public synchronized void call() {
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + " call");

    }

}

12.1.2 Lock版

public class Demo02 {
    public static void main(String[] args) throws InterruptedException {
    ReentrantLock lock = new ReentrantLock();
    lock.lock();
    lock.lock();   //连续加锁2次
    new Thread(() -> {
        System.out.println("线程2想要获取锁");
        lock.lock();
        System.out.println("线程2成功获取到锁");
    }).start();
    lock.unlock();
    System.out.println("线程1释放了一次锁");
    TimeUnit.SECONDS.sleep(1);
    lock.unlock();
    System.out.println("线程1再次释放了一次锁");  //释放两次后其他线程才能加锁
	}
}

结果:主线程——线程1,子线程——线程2。 一个ReentrantLock对象所以线程1和2的锁是同一把,如何线程1先拿了锁,所以线程2只能等线程1释放锁红才能拿到锁

线程1释放了一次锁
线程2想要获取锁
线程1再次释放了一次锁
线程2成功获取到锁

12.2 自旋锁

自旋锁是专为防止多处理器并发而引入的一种锁。自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。

前面Volatile的不能保证原子性的解决方法使用的就是CAS自旋锁

image

自旋锁加锁的两步骤及过程:

  • 第一步,查看锁的状态,如果锁是空闲的,则执行第二步;

  • 第二步,将锁设置为当前线程持有;

CAS 函数就把这两个步骤合并成一条硬件级指令,形成原子指令,这样就保证了这两个步骤是不可分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。

使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。这里的「忙等待」可以用 while 循环等待实现,不过最好是使用 CPU 提供的 PAUSE 指令来实现「忙等待」,因为可以减少循环等待时的耗电量。


注意:

  1. 需要注意,在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。

1)原理:

一个执行单元要想访问被自旋锁保护的共享资源,必须先得到锁,在访问完共享资源后,必须释放锁。如果在获取自旋锁时,没有任何执行单元保持该锁,那么将立即得到锁;如果在获取自旋锁时锁已经有保持者,那么获取锁操作将自旋在那里,直到该自旋锁的保持者释放了锁。


2)弊端:

1、死锁。试图递归地获得自旋锁必然会引起死锁:递归程序的持有实例在第二个实例循环,以试图获得相同自旋锁时,不会释放此自旋锁。

在递归程序中使用自旋锁应遵守下列策略:

递归程序决不能在持有自旋锁时调用它自己,也决不能在递归调用时试图获得相同的自旋锁。此外如果一个进程已经将资源锁定,那么,即使其它申请这个资源的进程不停地疯狂"自旋",也无法获得资源,从而进入死循环。


2、过多占用cpu资源。如果不加限制,由于申请者一直在循环等待,因此自旋锁在锁定的时候,如果不成功,不会睡眠,会持续的尝试,单cpu的时候自旋锁会让其它process动不了. 因此,一般自旋锁实现会有一个参数限定最多持续尝试次数. 超出后, 自旋锁放弃当前time slice. 等下一次机会。


12.3 互斥锁

每个对象都对应于一个可称为" 互斥锁" 的标记,这个标记用来保证在任一时刻,只能有一个线程访问该对象。

互斥锁mutex:独占锁;开销大

应用:synchronized关键字、ReentrantLock类


互斥锁原理:

互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞。 对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,于是就可以继续执行。如下图:

image

所以,互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。


  1. 那这个开销成本是什么呢?

    会有两次线程上下文切换的成本:

    • 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行;
    • 接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。
  2. 线程的上下文切换的是什么?

    当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。

    上下切换的耗时有大佬统计过,大概在几十纳秒到几微秒之间,如果你锁住的代码执行时间比较短,那可能上下文切换的时间都比你锁住的代码执行时间还要长。

    所以,如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁。


互斥锁和自旋锁区别


1)加锁失败后处理方式不同

当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对。

  • 互斥锁加锁失败后,线程会释放 CPU线程代码阻塞 ,给其他线程;

  • 自旋锁加锁失败后,线程会忙等待,直到它拿到锁;


2)适用范围不同

  • 互斥锁mutex:独占锁;开销大。

    pthread_mutex_lock(pthread_mutex_t *mutex);

    pthread_mutex_unlock(pthread_mutex_t *mutex);

  • 自旋锁spin lock:轻量级的锁,开销小;适用于短时间内对锁的使用。

    如果自旋锁已经被其他的执行单元保持,调用者就一直循环在那里判断该自旋锁是否被释放

    pthread_spin_lock(pthread_spinlock_t *lock);

    pthread_spin_unlock(pthread_spinlock_t *lock);


13. 死锁排查

13.1 死锁现象

image

死锁测试,怎么排除死锁?

public class DeadLockDemo {
    public static void main(String[] args) {
        String lockA = "lockA";
        String lockB = "lockB";
        new Thread(new MyThread(lockA, lockB), "T1").start();
        new Thread(new MyThread(lockB, lockA), "T2").start();
    }
}

class MyThread implements Runnable {
    private String lockA;
    private String lockB;

    public MyThread(String lockA, String lockB) {
        this.lockA = lockA;
        this.lockB = lockB;
    }

    @Override
    public void run() {
        synchronized (lockA) {
            System.out.println(Thread.currentThread().getName() + " lock:" + lockA + " want to get " + lockB);

            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            synchronized (lockB) {
                System.out.println(Thread.currentThread().getName() + " lock:" + lockB + " want to get " + lockA);
            }
        }

    }
}

输出(下面输出卡住,因为死锁了)

T1 lock:lockA want to get lockB
T2 lock:lockB want to get lockA

13.2 排查方式

打开idea的命令行输入口

  1. 使用jps定位进程号jps -l

image

  1. 使用jstack 端口号找到死锁问题

image

image

posted @ 2023-05-04 17:56  不吃紫菜  阅读(712)  评论(0编辑  收藏  举报