Java并发编程

Java并发编程

并发编程

线程与进程

进程和线程的概念

进程

  • 程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至CPU,数据加载至内存。进程就算用来加载指令、管理内存、管理IO的
  • 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。
  • 进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程。

线程

  • 一个进程之内可以分为多个线程
  • 一个线程就是一个指令流,将指令流的一条条指令以一定顺序交给CPU执行
  • Java中,线程作为最小调度单位,进程作为资源分配的最小单位。在windows中进程是不活动的,只是作为线程的容器。

并发和并行的概念

并行(parallel)是同一时间动手做(doing)多件事情的能力。

并发(concurrent)是同一时间应对(dealing with)多见事情的能力。

线程基本应用

应用之异步调用

从方法调用的角度来讲,如果

  • 需要等待结果返回,才能继续运行就是同步
  • 不需要等待结果返回,就能继续运行就算异步

应用之提高效率

java线程

创建和运行线程

  1. 直接使用Thread

  2. 使用Runnable配合Thread

    lambda简化

    Runnable r = () -> log.debug("running");
    
    Thread t = new Thread(() -> {log.debug("running");}, t2);
    

    原理之Thread与Runnable的关系

    • 创建线程的方法1是把线程和任务合并在一起,方法2是把线程和任务分开
    • 用Runnable更容易与线程池等高级API配合
    • 用Runnable让任务类脱离了Thread继承体系,更灵活
  3. FutureTask配合Thread

    FutureTask能够接受Callable类型的参数,用来处理有返回结果的情况

查看线程

观察多个线程同时运行

  • 交替执行
  • 先后顺序不受我们控制

查看进程方法

windows

  • 任务管理器可以查看进程也可以杀死进程
  • tasklist查看进程
  • taskkill杀死进程

linux

  • ps -fe查看所有进程
  • ps -fT -p 查看某个进程(PID)的所有线程
  • kill杀死进程
  • top按大写H切换是否显示线程
  • top -H -p 查看某个进程(PID)的所有线程

Java

  • jps命令查看所有Java进程
  • jstack 查看某个Java进程(PID)的所有线程
  • jconsole查看某个Java进程中线程的运行情况(图形界面)

线程API

原理之线程运行

栈与栈帧

我们都知道JVM(Java虚拟机栈)中由堆、栈、方法区所组成,其中栈内存是给线程使用的,每个线程启动后,虚拟机都会为其分配一块栈内存。

  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能由一个活动栈帧,对应着当前正在执行的那个方法

线程上下文切换(Thread Context Switch)

因为以下一些原因导致cpu不再执行当前的线程,转而执行另一个线程的代码

  • 线程的cpu时间片用完
  • 垃圾回收
  • 有更高优先级的线程需要运行
  • 线程自己调用了sleep、yield、wait、join、park、synchronized、lock等方法

当上下文切换发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,java中对应的概念就算程序计数器(Program Counter Register),作用是记住下一条jvm指令的执行地址,是线程私有的。

  • 状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
  • Context Switch频繁发生会影响性能

常见方法

方法名 功能说明 注意
start() 启动一个新线程,在新线程运行run方法中的代码 start方法只是让现场进入就绪,里面代码不一定立刻运行(CPU时间片还没分配)。每个线程的start方法只能调用一次,调用多次会出现IllegalThreadStateException
run() 新线程启动后调用的方法 如果构造Thread对象时传递了Runnable参数,则线程启动后会调用Runnable中的run方法,否则默认不执行任何操作。但可以创建Thread子类对象,来覆盖默认行为。
join() 等待线程运行结束
join(long n) 等待线程运行结束,最多等待n毫秒
getId() 获取线程id id唯一
getName() 获取线程名
setName() 修改线程名
getPriority() 获取线程优先级
setPriority() 修改线程优先级 java中规定线程优先级是1~10的整数,较大优先级能提高线程被CPU调度的几率
getState java中线程状态是用6个enum表示,分别为NEW、RUNNABLE、BLOCKED、WAITING、TIME_WAITING、TERMINATED
isInterrupted() 判断是否被打断 不会清除打断标记
isAlive() 线程是否存活
interrupt() 打断线程 如果被打断线程正在sleep、wait、join,会导致被打断线程抛出InterruptedException,并除打断标记;如果打断正在运行的线程,则会设置打断标记;park的线程被打断,不会设置打断标记
interrupted() 判断当前线程是否被打断 会清除打断标记
currentThread() 获取当前正在执行的线程
sleep(long n) 让当前执行的线程休眠n毫秒,休眠时让出cpu时间片
yield() 提示线程调度器让出当前线程对cpu的使用 主要为了测试和调试

sleep

  1. 调用sleep会让当前线程从Running进入Timed Waiting状态
  2. 其他线程可以使用interrupt方法打断正在睡眠的线程,这时sleep方法会抛出InterruptedException
  3. 睡眠结束后的线程未必会立刻得到执行

yield

  1. 调用yield会让当前线程Running进入Runnable状态,然后调度执行其他同优先级的线程。如果这时没有同优先级的线程,那么不能保证让当前线程暂停的效果。
  2. 具体实现依赖于操作系统的任务调度器

join

interrupt

如果被打断线程正在sleep、wait、join,会导致被打断线程抛出InterruptedException,并清除打断标记;如果打断正在运行的线程,则会设置打断标记;park的线程被打断,不会设置打断标记

两阶段终止模式

Two Phase Termination

f6df5ccae927b851

错误思路

  • 使用线程对象的stop()方法停止线程
    • stop方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁,其它线程将永远无法获取锁
  • 使用System.exit(int)方法停止线程
    • 目的仅是停止一个线程,但这种做法会让整个程序停止

打断park线程不会清除打断状态

线程优先级

  • 线程优先级会提示调度器优先调度该线程
  • 如果cpu较忙,那么优先级高的线程会获取更多的时间片,但cpu闲时,优先级几乎没作用

主线程与守护线程

默认情况下,Java进程要等待所有线程都运行结束才会结束。有一种特殊的线程叫守护线程,只要其他非守护线程运行结束,即使守护线程代码没有执行完全,也会强制结束。

t1.setDaemon(true);
  • 垃圾回收器线程就是一种守护线程
  • Tomcat中Acceptor和Poller线程都是守护线程,所以Tomecat接收到shutdown命令后,不会等待它们处理完当前请求。

线程状态

新建new -> run() -> 就绪ready -> start() -> 运行running -> sleep()/wait() -> 阻塞waiting/time_waiting/blocked -> 销毁terminated

共享模型之管程

共享问题

问题分析

对于i++而言(i为静态变量),实际会产生如下的JVM字节码指令

getstatic	i// 获取静态变量i的值
iconst_1	// 准备常量i
iadd		// 自增
putstatic	i// 将修改后的值存入静态变量i

临界区 Critical Section

  • 一个程序运行多个线程本身是没有问题的
  • 问题在多个线程访问共享资源
    • 多个线程读共享资源也没问题
    • 多个线程对共享资源读写操作时发生指令交错,就会出现问题
  • 一段代码块内存在对共享资源的多线程读写操作,称这段代码块为临界区

竞态条件 Race Condition

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件。

synchronized 解决方案

应用之互斥

  • 阻塞式的解决方案:synchronized, Lock
  • 非阻塞式的解决方案:原子变量

java中互斥和同步都可以采用 synchronized 关键字完成,他们的区别:

  • 互斥是保证临界区的竞态条件发生,同一时刻只能有一个线程执行临界区代码
  • 同步是由于线程执行的先后顺序不同,需要一个线程等待其他线程运行到某个点
sychronized(Object){
    临界区
}

synchronized 实际是用对象锁保证临界区代码的原子性

  • 如果把synchronized(obj)放在for循环外面,如何理解? --原子性
  • 如果t1 synchronized(obj1)而t2 synchronized(obj2)会怎样运作? --锁对象
  • 如果t1 synchronized(obj)而t2没有加会怎么样? --锁对象

方法上的synchronized

class Test{
    public synchronized void test(){      
    }
}
等价于
class Test{
    public void test(){
        synchronized(this){      
        }
    }
}

class Test{
    public synchronized static void test(){
        synchronized(Test.class){   
        }
    }
}
等价于
class Test{
    public static void test(){
        synchronized(Test.class){
        }
    }
}

不加synchronized的方法 不能保证线程安全

线程八锁

变量的线程安全分析

成员变量和静态变量是否线程安全?

  • 如果它们没有共享,则线程安全
  • 如果它们被共享了,根它们状态是否能改变,又分两种情况
    • 如果只有读操作,则线程安全
    • 如果有读写操作,则这段代码是临界区,需要考虑线程安全

局部变量是否线程安全?

  • 局部变量是线程安全的
  • 但局部变量引用的对象则未必
    • 如果该对象没有逃离方法的作用范围,它是线程安全的
    • 如果该对象逃离方法的作用范围,需要考虑线程安全

常见的线程安全类

  • String
  • Integer
  • StringBuffer
  • Random
  • Vector
  • HashTable
  • JUC包下的类

这里说的线程安全是指,多个线程调用它们同一个实例的某个方法时,是线程安全的,可以理解为:

  • 它们每个方法是原子的
  • 但注意它们多个方法的组合不是原子的
线程安全类方法的组合
不可变类线程安全性

String、Integer等都是不可变类,因为其内部状态不可以改变,因此它们的方法都是线程安全的。

Monitor概念

Monitor 监视器/管程

java普通对象头Mark Word(32 bits)、Klass Word(32 bits)

9a83edcf038f54df

e984f3206a8296fb

每个java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级)之后,之后的Mark Word中就被设置指向Monitor对象的指针。结构如下:

image-20230213161851737

  • 刚开始Monitor中Owner为null

  • 当Thread-2执行synchronized(obj)就会将Monitor的所有者Owner置为Thread-2,Monitor中只能有一个Owner

  • 在Thread-2上锁的过程,如果Thread-3,Thread-4也来执行synchronized,就会进入EntryList BLOCKED

  • Thread-2执行完同步代码块的内容,然后唤醒EntryList中等待的线程来竞争锁,竞争时是非公平的。

  • 途中WaitSet中的Thread-0,Thread-1是之前获得过锁,但条件不满足进入WAITING状态的线程。

  • Owner线程发现条件不满足,调用wait方法,即可进入WaitSet变为WAITING状态。

  • BLOCKED和WAITING线程都处于阻塞状态,不占用CPU时间片

  • BLOCKED线程会在Owner线程释放锁时唤醒

  • WAITING线程会在Owner线程调用notify或notifyAll时唤醒,但唤醒后并不意味着立即获得锁,而是进入EntryList重新竞争。

    注意:

    • synchronized必须是进入同一个对象的Monitor才有上述的效果。
    • 不加synchronized的对象不会关联监视器,不遵从以上规则。

原理之synchronized

1、轻量级锁

使用场景:如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁优化。

  • 创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word。

    4a21fe36c8ea790f

  • 让锁记录中Object Reference 指向锁对象,并尝试使用CAS替换Object的Mark Word,将Mark Word值存入锁记录。

    6fb70076410a3a89

  • 如果CAS替换成功,对象头中存储了锁记录地址和状态00,表示由该线程给对象加锁。

    aef4147bf7c8d705

  • 如果CAS失败,有两种情况

    • 如果是其他线程已经持有了该Object的轻量级锁,这时表明有竞争,进入锁膨胀过程。

    • 如果是自己执行了synchronized锁重入,那么再添加一条Lock Record作为重入的计数。

    ab7db3f3070ad6aa

  • 当退出synchronized代码块(解锁时)如果有取值为null的锁记录,表示有重入,这时重置锁记录,表示重入计数减一

    33a129f73e0f6fe1

  • 当退出synchronized代码块(解锁时)锁记录的值不为null,这时使用CAS将Mark Word的值恢复给对象头。

    • 成功,则解锁成功
    • 失败,说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程。
2、锁膨胀

如何尝试加轻量级锁的过程中,CAS操作无法成功,这种情况就是其他线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

  • 当Thread-1进行轻量级锁时,Thread-0已经对该对象加了轻量级锁

    c3528c9a331f4d92

  • 这时Thread-1加轻量级锁失败,进入锁膨胀流程

    • 即为Object对象申请Monitor锁,让Object指向重量级锁地址
    • 然后自己进入Monitor的EntryList BLOCKED

    2373f914a5293a0f

  • Thread-0退出同步块解锁时,使用cas将Mark Word的值恢复给对象头,失败。这时会进入重量级锁解锁流程,即按照Monitor地址找到Monitor对象,设置Owner为null,唤醒EntryList中BLOCKED线程

3、自旋优化

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时持锁线程退出了同步块,释放了锁),这时当前线程就可以避免阻塞。

  • 在Java6之后自旋锁是自适应的,比如对象刚刚一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;繁殖就少自旋甚至不自旋
  • 自旋会占用cpu时间,单核cpu自旋就是浪费,多喝cpu自旋才能发挥优势
  • Java7之后不能控制是否开启自旋功能
4、偏向锁

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要CAS操作。

Java6中引入了偏向锁来做进一步优化:只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID时自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归线程所有。

偏向状态

89f3c56757c7e5cd

一个对象创建时

如果开启了偏向锁,那么对象创建后,Mark Word值为0x05即最后三位为101,此时它的thread、epoch、age都为0。如果没有开启偏向锁,那么对象创建后,Mark Word值为0x01即最后3位为001.这是它的hashCode、age为0,第一次用到hashCode才会赋值。

撤销-其他线程使用对象:当有其他线程使用偏向锁对象时,会将偏向锁升级为轻量级锁。

撤销-调用对象hashCode:调用了对象的hashCode,但偏向锁的对象Mark Word中存储的是线程id,如果调用hashCode会导致偏向锁被撤销。

  • 轻量级锁会在锁记录中记录hashCode
  • 重量级锁会在Monitor中记录hashCode

撤销-调用wait/notify

批量重偏向

如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程t1的对象仍有机会重新偏向t2,重偏向会重置对象的ThreadID

当撤销偏向锁阈值超过20次后,jvm会认为偏向错对象,于是会在给这些对象加锁时重新偏向至加锁线程。

批量撤销

当撤销偏向锁阈值超过40次后,jvm会觉得偏向错了,不应该偏向。整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的。

5、锁消除

原理之wait/notify

image-20230217204055240

  • Owner线程发现条件不满足,调用wait方法,即可进入WaitSet变为WITING状态
  • BLOCKED和WAITING的线程都处于阻塞状态,不占用CPU时间片
  • BLOCKED线程会在Owner线程释放锁时唤醒
  • WAITING线程会在Owner线程调用notify或notifyAll时唤醒,但唤醒后并不意味着立刻获取锁,仍需进入EntryList重新竞争
API介绍
  • obj.wait()让进入object监视器的线程到waitSet等待
  • obj.notify()在object上正在waitSet等待的线程中挑一个唤醒
  • obj.notifyAll()让object上正在waitSet等待的线程全部唤醒

它们都是线程之间进行协作的手段,都属于Object对象的方法。必须获取此对象的锁才能调用方法。

wait/notify的正确姿势

sleep(long n)和wait(long n)的区别:

  1. sleep是Thread方法,而wait是Object方法
  2. sleep不需要强制和synchronized配合使用,但wait需要和synchronized一起用
  3. sleep在睡眠的同事,不会释放对象锁,但wait在等待时候会释放对象锁
  4. sleep进入状态TIMED_WAITING,wait进入状态WAITING。
sychronized(lock){
    while(条件不成立){
        lock.wait
    }
    // 干活
}
// 另一个线程
synchronized(lock){
    lock.notifyAll();
}

同步模式之保护性暂停

即Guarded Suspension,用在一个线程等待另一个线程执行结果。要点:

  • 有一个结果需要从一个线程传递到另一个线程,让他们关联同一个Guarded Object
  • 如果有结果不断从一个线程到另一个线程那么可以使用消息队列
  • JDK中,join、Future的实现就是采用此模式
  • 因为要等待另一方的结果,因此归类到同步模式

5d8287cc19dd8f6e

0336d71b853b5672

原理之join

异步模式之生产者/消费者

要点:

  • 与前面的保护性暂停中的GuardObject不同,不需要产生结果和消费结果的线程一一对应
  • 消费队列可以用来平衡生产和消费的线程资源
  • 生产者仅负责产生结果数据,不关心数据该如何处理,而消费者专心处理结果数据
  • 消息队列是有容量限制的,满时不会再加入数据,空时不会再消耗数据
  • JDK中各种阻塞队列,采用的就算这种模式

6b2429ffadf6273f

Park & Unpark

它们是LockSupport类中的方法

// 暂停当前线程
LockSupport.park();
// 恢复某个线程的运行
LockSupport.unpark(暂停线程对象);

特点,与Object的wait & notify 相比

  • wait、notify和notifyAll必须配合Object Monitor一起使用,而park & unpark不必
  • park & unpark是以线程为单位来【阻塞】和【唤醒】线程,而notify只能随机唤醒一个等待线程,notifyAll是唤醒所有等待线程,就不那么【精确】
  • park & unpark可以先unpark,而wait & notify不能先notify

原理:每个线程都有自己的Parker对象,由三部分组成_counter, _cond 和 _mutex

  • 线程就像一个旅人,Parker就像他随身携带的背包,条件变量就好比背包中的帐篷。_counter就好比背包中的备用干粮(0为耗尽,1为充足)。
  • 调用park就算要看需不需要停下来休息
    • 如果备用干粮耗尽,那么钻进帐篷歇息
    • 如果备用干粮充足,那么不需停留,继续前进
  • 调用unpark,就好比令干粮充足
    • 如果这时线程还在帐篷,就唤醒让他继续
    • 如果线程还在运行,那么下次调用park时,仅时消耗掉备用干粮,不需停留
      • 因为背包空间有限,多次调用unpark仅会补充一份备用干粮

7c84955a4be382d2

  1. 当前线程调用Unsafe.park()方法
  2. 检查_counter,本情况为0,这时获得_mutex互斥锁
  3. 线程进入_cond条件变量阻塞
  4. 设置_counter=0

0394694392f221cf

  1. 调用Unsafe.unpark(Thread_0)方法,设置_counter为1
  2. 唤醒_cond条件变量中的Thread_0
  3. Thread_0恢复运行
  4. 设置_counter为0

7b1b575e058d63f2

  1. 调用Unsafe.unpark(Thread_0)方法,设置_counter为1
  2. 当前线程调用Unsafe.park()方法
  3. 检查_counter,本情况为1,这时线程无需阻塞,继续运行
  4. 设置_counter为0

线程状态转换

dba7d137a23a757a

多把锁

多把不相干的锁

将锁粒度细分

  • 好处是可以增加并发度
  • 坏处是如果一个线程需要同时获得多把锁,容易发生死锁

活跃性

死锁 可以使用顺序加锁的方式解决,但是可能产生饥饿的问题。

定位死锁

检测死锁可以使用jconsole工具,或者使用jps定位进程id,再用jstack定位死锁

活锁 出现在两个线程互相改变对方的结束条件,最后谁也无法结束。

饥饿 一个线程由于优先级太低,始终得不到CPU调度执行,也不能够结束。

ReentrantLock

和synchronized一样支持可重入,相比synchronized有以下特点

  • 可中断
  • 可以设置超时时间
  • 可以设置为公平锁
  • 支持多个条件变量

可重入 指同一个线程如果首次获得这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁。

// 获取锁
reentrantLock.lock();
try{
    // 临界区
}finally{
    // 释放锁
    reentrantLock.unlock();
}

可打断

// 可被interrupt方法打断
lock.lockInterruptibly();

锁超时

lock.tryLock(1,Timeunit.SECONDS)

公平锁

// 公平锁
ReentrantLock lock = new ReentrantLock(true);

条件变量支持多个条件变量

  • await前需要获取锁
  • await执行后会释放锁进入conditionObject等待
  • await的线程被唤醒(或打断或超时)去重新竞争lock锁
  • 竞争lock锁成功后,从await后继续执行

小结

使用synchronized互斥解决临界区的线程安全问题

  • 掌握synchronized锁对象语法
  • 掌握synchronized加载成员方法和静态方法语法
  • 掌握wait/notify同步方法

使用lock互斥解决临界区的线程安全问题:可打断、锁超时、公平锁、条件变量

学会分析变量的线程安全性、掌握常见线程安全类的使用

活跃性问题:死锁、活锁、饥饿

应用方面

  • 互斥:使用synchronized或Lock达到共享资源互斥效果
  • 同步:使用wait/notify或Lock的条件变量来达到线程间通信效果

原理方面

  • monitor、synchronized、wait/notify原理
  • synchronized进阶
  • park & unpark原理

模式方面

  • 同步模式至保护性暂停
  • 异步模型之生产者消费者
  • 同步模式之顺序控制

共享模型之内存

Java内存模型JMM,即Java Memory Model,定义了主存、工作内存抽象概念,底层对应着CPU寄存器、缓存、硬件内存、CPU指令优化等。JMM体现在以下几个方面

  • 原子性-保证指令不会收到线程上下文切换的影响
  • 可见性-保证指令不会受cpu缓存影响
  • 有序性-保证指令不会受cpu指令并优化的影响

可见性

退不出的循环

static boolean run = true;
public static void main(String[] args) throws InterruptedException{
    Thread t = new Thread(() -> {
        whie(run){
            //...
        }
    });
    t.start();
    
    Thread.sleep(1000);
    log.debug("停止t");
    run = false;// 线程t不会如预想的停下来
}
  1. 初始状态,t线程刚开始从主内存读取了run的值到工作内存。

    ff766a76077633bc

  2. 因为t线程要频繁从主内存中读取run的值,JIT编译器会将run的值缓存至自己工作内存中的告诉缓存中,减少堆贮存中run的访问,提高效率

    a1891c561a0528d0

  3. 1秒后,main线程修改了run的值并同步至内存,而t是从自己工作内存中的告诉缓存中读取整个变量的值,结果永远是旧值

    f05cb3f2fe4bab5c

解决方法

volatile(易变关键字)它可以用来修饰成员变量和静态变量,可以避免线程从自己工作缓存中查找变量的值,必须从主内存中读取,线程操作volatile变量都是直接操作主存。

synchronized语句块既可以保证代码块的原子性,也可以保证代码块内变量的可见性。但是缺点是synchronized属于重量级操作,性能相对更低。

原子性

一个线程对volatile变量的修改对另一个线程可见,但不能保证原子性。

注意

synchronized语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized是属于重量级操作,性能相对更低。

有序性

JVM会在不影响正确性的前提下,调整语句的执行顺序。这种特性称之为【指令重排】,多线程下【指令重排】会影响正确性。

两阶段终止模式

使用共享变量的方式优雅的结束线程利用isInterrupt、利用停止标记

同步模式之犹豫模式

Balking 用在一个线程发现另一个线程已经做了某一件相同的事,那么本线程无需再做了,直接结束返回。

JVM会在不影响正确性的前提下,可以调整语句的执行顺序。

volatile原理

volatile的底层实现原理是内存屏障,Memory Barrier(Memory Fence)

  • 对volatile变量的写指令后会加入写屏障
  • 对volatile变量的读指令前会加入读屏障

保证可见性

  • 写屏障(sfence)保证在该屏障之前的,对共享变量的改动都同步到主存中。

  • 读屏障(lfence)保证在该屏障之后,读共享变量的读取,加载的是主存中最新数据。

    88a23f92819c9f9a

保证有序性

  • 写屏障会确保指令重排时,不会将写屏障之前的代码排在写屏障之后

  • 读屏障会确保指令重排时,不会将读屏障之后的代码排在读屏障之前

    9657c31d486ed2f5

但不能解决指令交错

  • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证跑到它前面去

  • 而有序性的保证也只是保证本线程内相关代码不被重排序

    b0cf80319c68a3c5

double-checked locking DCL问题

如果一个类始终只能创建一个实例,那么这个类被称为单例类,这种设计模式被称为单例模式。

public final class Singleton{
    private Singleton(){}
    private static /*volatile*/ Singleton INSTANCE = null;
    public static Singleton getInstance(){
        if(INISTANCE == null){
            synchronized(Singleton.class){
                if(INSTANCE == null){
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}
  • 懒惰实例化
  • 首次使用getInstance()才使用synchronized加锁,后续使用无需加锁
  • 有隐含的:第一个if使用了INSTANCE变量,是在同步代码块外面。
  • 代码块内的字节码会根据JVM优化而自动重排。而外面的if语句不是加锁的,在多线程环境下,第一个线程执行INSTANCE=new Singleton();有可能先赋值在调用Singleton对象的构造方法。而第二个线程在这时进入方法,判断INSTANCE不为null,直接范围对象,而实际上INSTANCE引用对象还没有初始化。

解决:volatile通过内存屏障可以解决指令重排的问题

happens-before

规定了对共享变量的写操作对其他线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下hasppens-before规则,JMM并不能保证一个线程对共享变量的写,对于其他线程对共享变量的读可见。

  • 线程解锁m之前对变量的写,对于接下来对m加锁的其他线程对变量的读可见

    static int x;
    static Object m = new Object();
    new Thread(() -> {
        synchronized(m){
            x = 10;
        }
    },"t1").start();
    new Thread(() -> {
        synchronized(m){
            System.out.println(x);
        }
    },"t2").start();
    
  • 线程对volatile变量的写,读接下来其他线程对改变了的读可见

    volatile static int x;
    new Thread(() -> {
        x = 10;
    },"t1").start();
    new Thread(() -> {
        System.out.println(x);
    },"t2").start();
    
  • 线程start前对变量的写,对该线程开始后对该变量的读可见

    static int x;
    x = 10;
    new Thread(() -> {
        System.out.println(x);
    },"t2").start();
    
  • 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其他线程调用t1.isAlive()或t1.join()等待它结束)

    static int x;
    Thread t1 = new Thread(() -> {
        x = 10;
    },"t1");
    t1.start();
    t1.join();
    System.out.println(x);
    
  • 线程t1打断t2(interrupt)前对变量的写,对于其他线程得知t2被打断后对变量的读可见

    static int x;
    public static void main(String[] args){
        Thread t2 = new Thread(() -> {
            while(true){
                if(Thread.currentThread().isInterrupted()){
                    System.out.println(x);
                    break;
                }
            }
        },"t2");
        t2.start();
        
        new Thread(() -> {
            Thread.sleep(1000);
            x = 10;
            t2.interrupt();
        }).start();
        
        while(!t2.isInterrupted()){
            Thread.yield();
        }
        System.out.println(x);
    }
    
  • 对变量默认值(0,false,null)的写,对其他线程对该变量的读可见

  • 具有传递性

    volatile static int x;
    static int y;
    new Thread(() -> {
        y = 10;
        x = 20;
    },"t1").start();
    new Thread(() -> {
        System.out.println(x);
    },"t2").start();
    

线程安全单例

// 问题1:为什么加final
// 问题2:如果实现了序列化接口,还要做什么来防止反序列化破坏单例
public final class Singleton implements Seriablizable{
    // 问题3:为什么设置为私有?是否能防止反射创建新的实例?
    private Singleton{}
    // 问题4:这样初始化是否能保证单例对象创建时的线程安全?
    private static final Singleton INSTANCE = new Singleton();
    // 问题5:为什么提供静态方法而不是直接将INSTANCE设置为public,说出你知道的理由
    public static Singleton getInstance(){
        return INSTANCE;
    }
}

小结

可见性

有序性

happens-before原则

原理方面

  • CPU指令并行
  • volatile

模式方面

  • 两阶段终止模式的volatile改进
  • 同步模式之Balking

共享模型之无锁

CAS和volatile

AtomicInteger balance = new AtomicInteger();
public void withdraw(Integer amount){
    while(true){
        int prev = balance.get();
        int next = prev - amount;
        if(balance.compareAndSet(prev,next)){
            break;
        }
    }
}

CAS必须借助volatile才能读取到共享变量的最新值来实现【比较并交换】的效果

CAS特点:结合CAS和volatile可以实现无锁并发,适用于线程数少,多核CPU的场景。

  • CAS时基于乐观锁的思想
  • synchronized时基于悲观锁的思想
  • CAS体现的时无锁并发,无阻塞并发,
    • 因为没有使用synchronized,所以线程不会陷入阻塞,这时效率提升的因素之一
    • 如果竞争激烈,重试必然频繁发生,效率反而受影响

原子整数

JUC并发包提供了AtomicBoolean、AtomicInteger、AtomicLong

updateAndGet & getAndUpdate

原子引用

  • AtomicReference
  • AtomicMarkableReference
  • AtomicStampedReference

主线程仅能判断出共享变量的值与最初值A是否相同,不能感知到从A改为B又改为A的情况(ABA问题)。如果主线程希望:只要有其他线程【动过】共享变量,那么自己的CAS就失败。这时,比较值是不够的,还需要再加一个版本号。

AtomicStampedReference可以给原子引用加上版本号,追踪原子引用整个的变化过程,可以知道变量中途被更改了几次。如果不关心引用变量更改了几次,只是单纯的关系是否更改过,则可以使用AtomicMarkableReference。

原子数组

  • AtomicIntegerArray
  • AtomicLongArray
  • AtomicReferenceArray

字段更新器

  • AtomicReferenceFieldUpdater
  • AtomicIntegerFieldUpdater
  • AtomicLongFieldUpdater

原子累加器

  • LongAdder
  • LongAccumulator
// 累加单元数组,懒惰初始化
transient volatile Cells[] cells;
// 基础值,如果没有竞争,则用cas累加这个域
transient volatile long base;
// 在cells创建或扩容时,置为1,表示加锁
transient volatile int cellsBusy;

原理之伪共享

因为CPU与内存速度差异很大,需要靠预读数据至缓存来提升效率。

而缓存以缓存行为单位,每个缓存行对应一块内存,一般64个字节。

缓存的加入造成数据副本的产生,即同一份数据会缓存在不同核心的缓存行中。

CPU耀保证数据一致性,如果某个CPU核心更改了数据,其他CPU核心对应的整个缓存行必须失效。

因为Cell是数组形式,在内存中连续存储的,一个Cell为24字节(16字节的对象头,和8字节的value),因此缓存行可以存下2个cell对象。那么问题来了:

  • Core-0要修改Cell[0]
  • Core-1要修改Cell[1]

无论谁修改成功,都会导致对方Core的缓存行失效。

@sun.misc.Contended 为了解决缓存行伪共享的问题。原理是使用此注解的对象或字段的前后方各增加128字节大小的padding,从而CPU将对象预读至缓存时占用不同的缓存行。

Unsafe

Unsafe对象提供了非常底层的,操作内存、线程的方法,Unsafe对象不能直接调用,只能通过反射获得。

Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");

函数式编程

// supplier 提供者 无中生有 ()->结果
// function 函数 一个参数一个结果 (参数)->结果,BiFunction(参数1,参数2)->结果
// consumer 消费者 一个参数没结果(参数)->void,BiCosumer(参数1,参数2)->

小结

CAS和volatile

API

  • 原子整数
  • 原子引用
  • 原子数组
  • 字段更新器
  • 原子累加器

Unsafe

原理

  • LongAdder源码
  • 伪共享

共享模型之不可变

日期转换的问题

SimpleDateFormat不是线程安全的,多线程时有很大几率出现java.lang.NumberFormatException或者出现不正确的日期解析结果。

不可变类的使用

DateTimeFormeatter对象是immutable、Thread-safe的。

不可变类设计

String类也是不可变的。

final的使用

String类、类中所有属性都是final的

  • 属性用final修饰保证了该属性是只读的,不能修改
  • 类用final修饰保证了该类中的方法不能被覆盖,防止子类无意间破坏不可变性

保护性拷贝

使用字符串时,有一些跟修改相关的方法,比如substring等,其内部是调用String的构造方法创建一个新字符串。构造方法构造新字符串对象时,会生成新的char[] value,对内容进行复制。这种通过创建副本对象来避免共享的手段称之为【保护性拷贝(defensive copy)】

模式之享元

享元模式Flyweight pattern,当需要重用数量有限的同一类对象时

包装类

在JDK中Boolean、Byte、Short、Integer、Long、Character等包装类提供了valueOf()方法,例如Long的valueOf会缓存-128~127之间的对象,在这个范围之间会重用对象,大于这个范围才会新建Long对象。

String串池
BigDecimal BigInteger

原理之final

设置final变量的原理:字节码发现final变量赋值也是通过putfield指令完成,指令之后加入写屏障,保证其他线程读它的时候不会出现0的情况

获取final变量的原理(结合字节码)

无状态

没有任何成员变量的类是线程安全的。因为成员变量保存的数据也可以称为状态信息,因此没有成员变量就称之为【无状态】

共享模型之并发工具

线程池

ThreadPoolExecutor

线程池状态

ThreadPoolExecutor使用int的高三位来表示线程池状态,低29位表示线程数量

状态名 高3位 接受新任务 处理阻塞队列任务 说明
RUNNING 111 Y Y
SHUTDOWN 000 N Y 不会接受新任务,但会处理阻塞队列剩余任务
STOP 001 N N 会中断正在执行的任务,并抛弃阻塞队列任务
TIDYING 010 - - 任务全执行完毕,活动线程为0,即将进入终结
TERMINATED 011 - - 终结状态

数字上比较,TERMINATED > TIDYING > STOP > SHUTDOWN > RUNNING

构造方法

  • corePoolSize 核心线程数目(最多保留的线程数)

  • maximumPoolSize 最大线程数目

  • keepAliveTime 生存时间

  • unit 时间单位

  • workQueue 阻塞队列

  • threadFactory 线程工厂

  • handler 拒绝策略

  • 线程池刚开始没有线程,当一个任务提交给线程池后, 线程池会创建一个新线程来执行任务

  • 当线程数达到corePoolSize并没有线程空闲,这时再加入任务,新加的任务会被加入workQueue队列排队,直到有空闲的线程

  • 如果队列选择了有界队列,那么任务超过了队列大小时,会创建maximumPoolSize-corePoolSize数目的线程来救急。

  • 如果线程到达maximumPoolSize仍然有新任务这时执行拒绝策略。拒绝策略jdk提供了4中实现:

    • AbortPolicy抛出RejectedExecutionExeception异常,默认策略
    • CallerRunsPolicy让调用者自己选择如何运行任务
    • DiscardPolicy放弃本次任务
    • DiscardOldestPolicy放弃队列中最早的任务,本任务取代之
  • 高峰过去后,超过corePoolSize的救济线程如果一段时间没有任务做,需要结果线程节省资源,这一时间keepAliveTime和unit控制

newFixedThreadPool

  • 核心线程数 == 最大线程数(没有救急线程),无需超时时间
  • 阻塞队列是无界的,可以放任意数量任务

newCachedThreadPool

  • 核心线程数为0,最大线程数是Integer.MAX_VALUE,救急线程的空闲生存时间为60s
    • 全部都是救急线程(60s后可以回收)
    • 救急线程可以无线创建
  • 队列采用synchronousQueue实现特点是,没有容量,没有线程来取是放不进去的(一手交钱一手交货)

newSingleThreadExecutor

  • 多个任务排队执行,线程数固定为1,任务书多于1时放入无界队列排队。任务执行完毕,这唯一的线程也不会被释放

提交任务

// 执行任务
void execute(Runnable command);
// 提交任务task,用返回值Future获得任务执行结果
<T> Future<T> submit(Callable<T> task);
// 提交tasks中所有任务
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks) throws InterruptedException;
// 提交tasks中所有任务,带超时时间
<T> List<Future<T>> invokeAll(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit) throws InterruptedException;
// 提交tasks中所有任务,哪个任务先成功执行完毕,返回此任务执行结果,其他任务取消
<T> T invokeAny(Collection<? extends Callable<T>> tasks) throws InterruptedException, ExecutionException;
// 提交tasks中所有任务,哪个任务先成功执行完毕,返回此任务执行结果,其他任务取消
<T> T invokeAny(Collection<? extends Callable<T>> tasks, long timeout, TimeUnit unit) throws InterruptedException, ExecutionException;

关闭线程池

/*
线程池状态变为SHUTDOWN
- 不会接受新任务
- 但已提交任务会执行完
- 此方法不会阻塞调用线程的执行
*/
void shutdown();
// 尝试终结
tryTerminate();
// 不在RUNNING状态的线程池,此方法返回true
boolean isShutdown();
// 线程池状态是否是TERMINATED
boolean isTerminated();
// 调用SHUTDOWN后,由于调用线程并不会等待所有任务运行结束,因此如果它像在线程池TERMINATED后做些事情,可以利用此方法等待
boolean awaitTerminated(long timeout, TimeUnit unnit) throws InterruptedException;

异步模式之工作线程

让有限的工作线程(Worker Thread)来轮流异步处理无限多的任务,也可以归类为分工模式。典型实现就算线程池。

对比另一个多线程设计模式(Thread-Per-Message)。

不同任务类型应该使用不同的线程池,这样能避免饥饿,并能提升效率。

饥饿

固定大小线程池会有饥饿现象

  • 两个工人是同一线程池中的两个线程
  • 他们要做的事情,点餐和做菜,这是两个阶段的工作
    • 点餐:必须先点餐,等菜做好再上菜,此期间点餐工人必须等待
    • 做菜
  • 同时来了两个客人,工人A和工人B都处理点餐,没人做饭,饥饿现象。
创建多少线程池合适
  • 过小回导致程序不能充分利用系统资源、容易导致饥饿
  • 过大会导致更多的线程上下文切换,占用更多内存
CPU密集型运算

通常使用CPU核数+1能实现最优的CPU利用率,+1是保证当线程由于页缺失故障(操作系统)或其它原因导致暂停时,额外的这个线程就能顶上去,保证CPU时钟周期不被浪费

I/O密集型运算

线程数 = 核数 * 期望CPU利用率 * 总时间(CPU计算时间+等待时间) / CPU计算时间

任务调度线程池

java.util.Timer可以实现定时功能,Timer优点在于简单易用,但由于所有任务都是由同一个线程来调度,因此所有任务都是串行执行的,同一时间只能有一个任务执行,前一个任务的延迟或异常都将会影响到之后的任务。

ScheduledExecutorService pool = Executors.newScheduledThreadPool(2);
pool.schedule(() -> {},1,TimeUnit.SECONDS);

正确处理线程池异常

Future<boolean> f = pool.submit(() -> {
    int i = 1 / 0;
    return true;
});
System.out.println(f.get());

Tomcat线程池

504025eac1ea5683

  • LimitLatch用来限流,用来控制最大连接个数,类似JUC中的Semaphore
  • Acceptor只负责【接受新的socket连接】
  • Poller只负责监听socket channel是否有【可读的I/O事件】
  • 一旦可读,封装一个任务对象(socketProcessor),提交给Executor线程池处理
  • Executor线程池中的工作线程最终负责【处理请求】

Tomcat线程池扩展了ThreadPoolExecutor

  • 如果总线程达到maximumPoolSize
    • 这是不会立刻抛出RejectedExecutionException异常
    • 而是再次尝试放入队列,如果还失败,才抛出异常

Fork/Join

Fork/Join是1.7加入的新的线程池实现,体现了分治思想,使用与能够进行任务拆分的cpu密集型运算

使用

提交给Fork/Join线程池的任务需要继承RecursiveTask(有返回值)或RecursiveAction(无返回值)。

JUC

AQS原理

AbstractQueuedSynchronizer,是阻塞式锁和相关同步器工具的框架

  • 用state属性表示资源状态(独占模式和共享模式),子类需要定义如何维护中国状态,控制如何获取锁和释放锁
    • getState-获取状态
    • setState-设置状态
    • compareAndSetState-CAS机制设置state状态
    • 独占模式只有一个线程能够访问资源,共享模式可以允许多个线程访问资源
  • 提供了基于FIFO的等待队列,类似于Monitor的EntryList
  • 条件变量来实现等待、唤醒机制,支持多个条件变量,类似于Monitor的WaitSet

ReentrantLock原理

非公平锁原理
可重入原理
可打断原理
不可打断模式

在此模式下,即使它被打断,仍会驻留在AQS队列中,等获得锁后方能继续运行(打断标记被设置为true)

可打断模式

在park过程中如果被interrupt会抛出异常不会再次进入死循环

公平锁原理

与非公平锁主要区别在于tryAcquire方法实现,公平锁会先检查AQS队列中是否有前驱节点,没有才竞争

条件变量实现原理

await流程

signal流程

读写锁

线程安全集合类

80090f9a7556d740

Semaphore

CountdownLatch

CyclicBarrier

ConcurrentHashMap原理

LongAdder value = map.computeIfAbsent(key,(key) -> new LongAdder());
value.increment;

JDK 7 HashMap并发死链复现

  • 究其原因,是因为在多线程环境下使用非线程安全的map集合
  • JDK8虽然将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序),但仍不意味着能够在多线程环境下能安全扩容,还会出现其他问题(如扩容丢数据)

CouncurrentLinkedQueue原理

BlockingQueue

CopyOnWriteArrayList

disruptor

guava

皖ICP备2023003517号-1