多线程编程

1 多线程编程基础
1.1 进程与程序
进程是程序的运行实例,类似播放中的视频和视频文件的关系

1.2 创建线程的两种方式
继承Thread重写run方法
提供Thread对象一个Runnable对象
1.3 Thread与Rnnable的关系
Thread是对线程的抽象,Runnable是对线程要执行的任务的抽象

1.4 线程的属性
ID:不适合用作某种唯一标识
Name:有助于代码调试和问题定位
Daemon
true表示守护线程,false表示用户线程
main方法由用户线程执行
Java虚拟机只有在所有用户线程都运行结束(Thread.run结束)后才能正常停止,而守护线程不影响虚拟机正常停止
kill -9不属于正常停止,而是强制停止,因此无论用户线程是否都结束,kill -9都能停止Java虚拟机
Prioriy:一般设置为默认优先级即可
1.5 Thread常用方法
static currentThread:返回当前线程对象
start:启动线程
join:线程A调用线程B的join方法,那么线程A的运行会被暂停,直到线程B运行结束
sleep:使当前线程暂停运行指定的时间
不可靠的方法:yield
废弃的方法:stop、suspend、resume
1.6 Java中的线程
main线程
Java垃圾回收线程
JIT编译器将Java字节码编译成处理器可以直接执行的机器码使用的线程
1.7 Java的线程生命周期
通过Thread.getState()可以获取线程状态
活跃线程:READY状态的线程
Thread.getState的返回值中是不包含READY和RUNNING的,这两个状态统一返回RUNNABLE,READY和RUNNING只是操作系统中对线程状态的细化
精确来说,从BLOCKED、WAITING、TIMED_WAITING恢复成READY而不是RUNNING
时间分片结束也会从RUNNING变为READY

1.8 线程转储(Thread Dump)
线程转储包含了获取这个线程转储的那一刻该程序的所有线程信息
获取方式
Oracle JDK:jstack -l pid
IBM JDK
系统宕机自动产生,使用jca467.jar分析
java -jar surgery.jar -pid pid -command JavaDump
WebLogic:监视–线程–转储线程堆栈
linux:kill -3 pid
1.9 多线程的优势
充分利用多核处理器:一个线程同一时间只能被一个处理器处理
提高系统吞吐率:IO等待时可以先执行其他线程
提高响应性:一个请求慢了不会影响其他请求
最小化对系统资源的使用:多线程公用一块内存
1.10 多线程的风险
线程安全问题
线程活性问题
上下文切换:处理器从执行一个线程转向执行另一个线程时,操作系统需要执行的动作
可靠性:线程如果内存溢出,会导致整个进程瘫痪,进而进程中其他线程也无法正常执行
2 线程编程的目标与挑战
2.1 串行、并发与并行
串行:1个人,先做A事情,A结束才能继续做B事情
并发:1个人,先做A事情的准备工作,等待A结束这段时间,做B事情
并行:2个人,一个人做A事情,一个人做B事情
1个cpu处理多个线程是并发,N个cpu处理N个线程是并行
多线程的本质就是将串行改为并发
2.2 竞态(race condition)
竞态:指一种现象,一个程序的输出依赖于不受控制的事件出现顺序或者出现时机
竞态产生原因:多个线程对同一组共享变量的读取和更新操作交错进行,也就是不具备原子性
可能产生竞态的模式
read-modify-write:sequence++
check-then-act:条件语句
消除竞态,其实就是保证原子性
同一时刻只有一个线程对共享变量进行读或写:利用锁的排他性
多个线程不使用共享变量
2.3 线程安全
所谓线程安全,就是线程的执行结果和预期的一致(运作正常)
线程安全的类:就是多个线程中可以使用该类的同一个对象,最终使用的结果和预期的一致
竞态会导致线程不安全,但线程不安全不一定都是由于竞态导致
常见线程不安全的类
ArrayList:插入数据丢失
HashMap:死循环和内存泄漏
SimpleDateFormat
将类做成线程安全的类需要额外代价
状态变量:实例变量或静态变量
共享变量:有可能被多线程共同访问的变量,状态变量就是共享变量
导致线程不安全的几个方面
原子性:对于同一组共享变量读写的两块操作对于对方来说如果不具备原子性就可能导致线程不安全
一定是对同一组共享变量,因为如果两个操作读写的不是同一组共享变量,甚至读写的是局部变量,那么即使两个操作之间不具备原子性,交错执行,也不会导致线程不安全
有序性:如果一个线程的访问一组共享变量的操作,感受到的另一个线程对这组共享变量的内存访问操作与源码顺序不同,就可能导致线程不安全,通常来说对多个共享变量更新或对单个共享变量多次更新,才会导致有序性造成线程安全问题
可见性:如果一个线程对某个共享变量进行更新之后,后续访问该变量的线程无法立刻读取到该更新的结果,就可能导致线程不安全
2.4 原子性
2.4.1 概念
本意是指不可分割的性质
原子操作:本意指不可分割的操作,例如sequence = 10是原子操作,sequence++不是原子操作,因为该操作可拆为如下3个操作
load(sequence, r1); // 指令①:将变量sequence的值从内存读到寄存器r1
increment(r1); // 指令②:将寄存器r1的值增加1
store(sequence, r1); // 指令③:将寄存器r1的内容写入变量sequence所对应的内存空间
多线程编程中所谓的原子性:指不同线程上执行的两块访问同一组共享变量操作,它们对于对方来说可以看成一个操作,无法分割,即两块操作间无法交错执行,就认为这两块操作对于对方来说具备原子性
A操作对于B操作为原子操作,不见得对C操作也是原子操作,因为很可能A和C可以交错执行
2.4.2 Java如何保证两块操作对于对方来说具备原子性
通过为两块操作加同一个锁:利用锁的排他性保证两块操作只能串行执行,也就不会出现交错执行的问题,也因此这两块操作对于对方来说具备原子性
使用CAS构造的代码
因此为了避免原子性引起的线程不安全,只需为可能在多个线程上执行的,对于同一组共享变量读写的两块操作,加上同一个锁,或使用CAS构造的代码替代对于对方来说不具备原子性的操作即可
2.4.3 Java读写操作的原子性
对任何类型的变量的读操作都具备原子性
对除long型和double型以外的任何类型的变量的写操作都具备原子性
也就是说A线程将long型的变量a的值写为5时,由于写long型变量不具备原子性,因此写入一半时,可能执行B线程读变量a的操作,导致读取到的值既不是初始值0,也不是最终的值5
如果使用volatile关键字修饰long和double类型的变量可以保证该变量的写也具备原子性
2.4.4 避免原子性带来的线程安全问题的最佳实践
考虑对同一组共享变量的读写加同一把锁
2.5 可见性
2.5.1 概念
如果一个线程对某个共享变量进行更新之后,后续访问该变量的线程可以立刻读取到该更新的结果,那么我们就称这个线程对该共享变量的更新对其他线程可见,否则称为不可见
默认情况下,一个线程对共享变量的更新对其他线程不可见
一个线程更新了该变量的值之后,其他线程能够立即读取到这个更新后的值,那么这个值就被称为该变量的相对新值 。如果读取这个共享变量的线程在读取并使用该变量的时候其他线程无法更新该变量的值(被加锁了),那么该线程读取到的相对新值就被称为该变量的最新值
2.5.2 不可见的原因
JIT编译器对代码进行了优化

while (! toCancel) {
if (doExecute()) {
break;
}
}

//JIT编译器可能会为了避免重复读取状态变量toCancel以提高代码的运行效率,对代码进行优化
//这就导致即使toCancel被另一个线程修改,当前线程也确实看到了这个修改,也无法跳出循环
//经过测试,doExecute中有系统调用,类似sout、Thread.sleep时,并不会被优化
if (! toCancel) {
while (true) {
if (doExecute()) {
break;
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
与计算机的存储子系统有关

每个cpu有各自的寄存器、高速缓存,但共用主存,即都能读取到主存中的数据
cpu只能对寄存器中数据进行运算,因此读变量时,cpu会先将数据从高速缓存复制到寄存器,如果数据不在高速缓存,那么需要先从主存复制到高速缓存再复制到寄存器,写变量时相反,因此各cpu读写的数据其实是主存中数据的副本
一个cpu无法读取到另一个cpu上寄存器中的内容,但当发现自身高速缓存中数据无效后,可以读到另一个cpu高速缓存中的内容(通过缓存一致性协议进行缓存同步)
由于缓存一致性带来的性能问题,cpu引入写缓冲器和无效化队列,对数据的修改会先放入写缓冲器,而读取数据也会先从写缓冲器中读取,同时一旦接到通知,发现自身高速缓存中某些数据无效,不会直接清理高速缓存中内容,而是先将这些内容放入自身的无效化队列中,等到清理无效化队列时,才真正将高速缓存中对应数据清除
一个变量可能被分配到寄存器中存储,也可能分配到主存中存储
如果在寄存器:一个cpu进行了修改,另一个cpu中不可见
如果在主存:一个cpu进行了修改,会先将数据放入自身的写缓冲器,在使用写缓冲器中数据更新高速缓存(冲刷处理器缓存)中数据之前,另一个cpu不可见,即使冲刷处理器,但另一个cpu在清空自身无效化队列前(刷新处理器缓存),不会发现自身高速缓存中数据无效,仍然会从自身的写缓冲器或高速缓存中读取数据,仍然看不见另一个cpu对该数据的更改
2.5.3 保障可见性
修改数据的cpu在修改数据后,立即冲刷处理器,即清空写缓冲器
读取数据的cpu在读取数据前,刷新处理器缓存,即清空无效化队列
2.5.4 Java中保障可见性
使用volatile修饰共享变量

可以阻止JIT编译器的错误优化
volatile修饰的变量的写操作后,会强制cpu冲刷处理器缓存
volatile修饰的变量的读操作前,会强制cpu刷新处理器缓存
使用锁

获取锁后,临界区前,会强制cpu冲刷处理器缓存
临界区后,释放锁前,会强制cpu冲刷处理器缓存
父线程在启动子线程之前对共享变量的更新对于子线程来说可见的

public class ThreadStartVisibility {
static int data = 0;

public static void main(String[] args) {

Thread thread = new Thread() {
  @Override
  public void run() {
    Tools.randomPause(50);
    //打印的值可能是1或2,但一定不是0
    System.out.println(data);
  }
};

// 在子线程thread启动前更新变量data的值,对子线程可见
data = 1;
thread.start();
Tools.randomPause(50);
// 在子线程thread启动后更新变量data的值,不可见
data = 2;

}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
一个线程终止后该线程对共享变量的更新对于调用该线程的join方法的线程而言可见

public class ThreadJoinVisibility {
static int data = 0;

public static void main(String[] args) {
Thread thread = new Thread() {
@Override
public void run() {
Tools.randomPause(50);
data = 1;
}
};
thread.start();
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
//打印的一定是1
System.out.println(data);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2.5.5 避免可见性带来的线程安全问题的最佳实践
考虑为需要可见的共享变量的读写操作加锁,或使用volatile修饰该共享变量
2.6 有序性
2.6.1 概念
指一个处理器上运行的一个线程所执行的对一组共享变量的内存访问操作在另外一个处理器上运行的其他线程的访问这组共享变量的操作看来是否与源码顺序相同
对于另一个处理器上,非访问共享变量的操作,即使看来无序,也不会影响线程安全
2.6.2 重排序
重排序是对内存访问有关的操作(读和写)所做的一种优化,它可以在不影响单线程程序正确性的情况下提升程序的性能,但是,它可能对多线程程序的正确性产生影响,即它可能导致线程安全问题

2.6.3 指令重排序
编译器:Java中编译器(javac)不会造成重排序,即字节码和源码顺序一定一致
JIT编译器:编译后的机器码可能和字节码顺序不一致,例如DCL单例
CPU:CPU对机器码的执行顺序可能和机器码本身的顺序不一致,例如if中内容会先执行
2.6.4 存储子系统重排序
存储子系统重排序也称为内存重排序
内存重排序是由于写缓冲器和无效队列引起的,写线程中先写入的变量后进入高速缓存,另一个线程就会感觉写操作排到了后面,或读线程中,后读取的变量在无效化队列,另一个线程就会感觉读操作排到了前面,从而产生内存重排序
2.6.5 貌似串行语义
如果所有内容都可以肆无忌惮的重排序,那么单线程下程序执行结果都可能出现问题
因此重排序会遵守一定的规则,至少保证单线程下的执行结果不会因为重排序而出现问题
例如对同一变量的读操作和写操作不能重排序
在这种规则限制的前提下,单线程执行的结果不会出现问题,因此在单线程看来,根本没有重排序,程序是一条条串行执行的,因此叫貌似串行语义
2.6.6 避免可见性带来的线程安全问题的最佳实践
不必禁止所有重排序,只需禁止那些导致线程安全问题的重排序即可
只需从逻辑上禁止重排序即可,而不是从物理上禁止重排序
底层使用内存屏障,Java层使用volatile和synchronized关键字
2.7 上下文切换
上下文:线程的进度信息,通常包括通用寄存器的内容和程序计数器的内容

当一个线程被暂停,被剥夺处理器的使用权(切出),另外一个线程被选中开始或者继续运行(切入),就会发生上下文的切换,将切出线程的上下文保存到内存,将切入线程在内存中的上下文信息恢复到寄存器和程序计数器中

Java中,发生上下文切换的场景

RUNNABLE --> 非RUNNABLE:线程被暂停
非RUNNABLE --> RUNNABLE:线程被唤醒
需要注意虽然Java中没有READY和RUNNING状态,但如果非细分一下,非RUNNABLE --> READY,不会发生上下文切换,从READY --> RUNNING才会发生上下文切换

上下文切换的开销

操作系统保存和恢复上下文所需的开销
线程调度器进行线程调度的开销
处理器高速缓存重新加载的开销:因为一个处理器执行一半的线程可能会交给另一个处理器执行,那么另一个处理器需要通过高速缓存获取该线程在原处理器高速缓存中的上下文
一级高速缓存中的内容被冲刷进二级缓存甚至主存
监控Java程序上下文切换的次数和频率

#使用linux内核提供的perf命令
perf stat -e cpu-clock, task-clock, cs, cache-references, cache-misses java io.github.viscent.mtia.ch1.FileDownloaderApp http://server.com/a.png http://server.net/b.png http://server.info/c.png

#结果为653次,0.004M次每秒
653 cs # 0.004 M/sec
1
2
3
4
5
2.8 资源争用与调度
排他性资源:一次只能够被一个线程占用的资源,例如cpu、文件、数据库连接

争用:多个线程想访问同一个排他性资源

资源调度:选择哪个申请者占用资源

资源持有线程:获得资源的独占权而又未释放其独占权的线程

资源调度策略:以锁为例

资源调度器内部维护一个等待队列
申请资源失败的线程会被暂停,并放入等待队列
当资源被其持有线程释放,将等待队列中的一个线程唤醒,变为READY状态
当其变为RUNNING,再次申请资源,如果失败又会被暂停,如果成功,则移出队列
非公平:随机唤醒一个线程,且与其他RUNNABLE线程(未进入过队列)都有机会先被CPU选中执行,从而申请到资源,而且其他RUNNABLE线程申请到资源概率更大
公平:唤醒队列头部的线程(也可能是队列中某个线程),其他RUNNABLE线程发现等待队列中有线程,就直接被暂停并放入队列尾部,因此根本不会被CPU选中执行,因此被唤醒的线程一定能申请到资源
公平与非公平策略特点与选择

公平
吞吐率低:因为想申请资源的线程,必须排到队列尾部并暂停,一定导致上下文切换,而如果是非公平策略,其他RUNNABLE线程可能会直接获取到锁,而且如果在队列中被唤醒的线程申请资源前,如果就能执行完成并将资源释放,那么队列中被唤醒的线程就不会再次被暂停,减少上下文切换次数,提升了吞吐率
资源申请者申请资源所需的时间基本相同
适用于每个线程占用资源时间都较长,或要求源申请者申请资源所需的时间基本相同的情况
非公平
吞吐率高
资源申请者申请资源所需的时间偏差可能较大,并可能导致饥饿现象
默认都应使用非公平策略
3 Java线程同步机制
3.1 线程同步机制
协调线程间的数据访问及活动的机制,该机制用于保障线程安全的机制
Java平台提供的线程安全机制

volatile关键字
final关键字
static关键字
相关的API
3.2 锁
临界区:获得锁和释放锁之间的代码,称为对应锁引导的临界区,该锁称为该临界区的引导锁
分类
内部锁:synchronized
显示锁:Lock接口,ReentrantLock实现,CAS实现
3.2.1 锁的作用
锁如何保障线程安全
原子性:通过互斥保障原子性
可见性:锁的获得隐含着刷新处理器缓存,释放隐含着冲刷处理器缓存
有序性:锁的获取无法与临界区内代码重排序,锁的释放也不能与临界区内代码重排序,再通过原子性和可见性,就共同保障了有序性,即虽然临界区内外各自还是能重排序,但不会导致其他线程看来当前线程临界区代码执行顺序与源码顺序不同
锁保障线程安全的前提是,多个线程在访问同一组共享数据的时候必须使用同一个锁,且仅读共享数据而没有写的情况下,读操作也需要加锁
3.2.2 锁相关概念
可重入性:一个线程在其持有一个锁的时候是否可以再次申请该锁
可重入锁
可重入锁可以被理解为一个对象,该对象包含一个计数器属性。计数器属性的初始值为0,表示相应的锁还没有被任何线程持有
每次线程获得一个可重入锁的时候,该锁的计数器值会被增加1
每次一个线程释放锁的时候,该锁的计数器属性值就会被减1
一个可重入锁的持有线程初次获得该锁时相应的开销相对大,这是因为该锁的持有线程必须与其他线程“竞争”以获得锁
可重入锁的持有线程继续获得相应锁所产生的开销要小得多,这是因为此时Java虚拟机只需要将相应锁的计数器属性值增加1即可以实现锁的获得
synchronized和ReentrantLock都是可重入锁
锁的争用与调度:因为锁也是排他性资源,因此也有公平和非公平两种策略
锁的粒度:一个锁实例所保护的共享数据的数量大小,粒度过粗会导致不必要的等待,过细会增加锁调度的开销
3.2.3 锁的开销
申请释放的开销
上下文切换的开销
锁泄漏:由于程序错误导致锁一直无法释放
3.3 内部锁
Java平台中,任何一个对象,都有唯一一个和其关联的锁,称为监视器,或内部锁

内部锁通过synchronized关键字实现

同步方法:synchronized修饰的方法

static方法:引导锁为static方法所在类对应的Class对象关联的锁
非static方法:引导锁为调用该方法的对象关联的锁
同步块:synchronized修饰的代码块,引导锁为锁句柄这个对象关联的锁

//通常使用private final修饰,防止被修改,从而无法保证线程安全
synchronized(锁句柄) {

}
1
2
3
4
内部锁的使用不会引起锁泄漏,因为锁的获取和释放都由java完成,类似在finally中释放锁

内部锁的申请、释放、调度都由Java虚拟机负责代为实施,是非公平策略

3.4 显式锁
java1.5后提供,将显式锁抽象为Lock接口,ReentrantLock是Lock接口的默认实现

使用模版

private final Lock lock = new ReentrantLock();

lock.lock();
try{
//临界区
}finally {
lock.unlock();
}
1
2
3
4
5
6
7
8
ReentrantLock默认为非公平锁,可以通过new ReentrantLock(true)创建公平锁

显示锁优缺点

编程灵活:内部锁的申请与释放只能是在一个方法内进行,而显式锁支持在一个方法内申请锁,却在另外一个方法里释放锁

锁泄漏:内部锁不会锁泄漏,但显示锁会

显示锁可以避免无法获取锁造成的代码无法继续

private final Lock lock = new ReentrantLock();
if (lock.tryLock()) {
try {
//临界区
} finally {
lock.unlock();
}
} else {
//获取锁失败时的操作,不必等待
}
1
2
3
4
5
6
7
8
9
10
公平:显示锁既支持公平锁,又支持非公平锁,而内部锁只支持非公平锁

打印锁的相关信息:显式锁提供了一些接口(指方法)可以用来对锁的相关信息进行监控,例如isLocked,getQueueLength

性能

Java 1.6/1.7对内部锁做了一些优化,这些优化在特定情况下可以减少锁的开销
锁消除
锁粗化
偏向锁
适配性锁
Java 1.5中,在高争用的情况下,内部锁的性能急剧下降,而显式锁的性能下降则少得多,即显式锁可伸缩性好,但Java1.6后,由于进行了优化,可伸缩性差异已经很小
读写锁

读线程:对共享变量仅进行读取而没有进行更新的线程

写线程:对共享变量进行更新的线程

读写锁中包含读锁和写锁

读锁:共享的,即有线程持有读锁时,其他线程可以获取读锁,但无法获取写锁(因为写锁排他)
写锁:排他的,有线程持有写锁时,其他线程既无法获取写锁,也无法获取读锁
Java中使用ReadWriteLock表示读写锁,ReentrantReadWriteLock为其默认实现

其有两个方法,readLock和writeLock分别用于返回相应读写锁实例的读锁和写锁,并不是返回两种锁,而是表示同一个锁可以充当两种角色

示例

public class ReadWriteLockUsage {
//final表示防止被修改,导致两个线程获取的不是同一个锁
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
//创建读锁
private final Lock readLock = rwLock.readLock();
//创建写锁
private final Lock writeLock = rwLock.writeLock();
//读线程的方法
public void reader() {
readLock.lock();
try {
//读取共享变量
} finally {
readLock.unlock(); // 总是在finally块中释放锁,以免锁泄漏
}
}

//写线程的方法
public void writer() {
writeLock.lock();
try {
//更新共享变量
} finally {
writeLock.unlock();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
读写锁使用场景:必须同时满足,否则得不偿失,开销更大,会更慢

只读操作比写操作要频繁得多
读线程持有锁的时间比较长
写锁中可以嵌套申请读锁,称为锁的降级,但读锁中不能嵌套申请写锁,即不支持升级,必须先释放读锁,再申请写锁

3.5 锁的使用场景
可能产生竞态的场景
check-then-act操作:一个线程读取共享数据并在此基础上决定其下一个操作是什么
read-modify-write操作:一个线程读取共享数据并在此基础上更新该数据
某些像自增操作count++这种对单个共享变量的read-modify-write操作,可以使用原子变量类来替代锁
对存在关联关系的一组共享数据进行更新时,为了保障操作的原子性我们可以考虑使用锁
后续内容也会介绍一种该场景下的替代线程同步机制
3.6 内存屏障
内存屏障是一种指令,由JIT编译器插入到两个指令之间,目的是禁止cpu造成的重排序和存储子系统造成的重排序
cpu一旦发现内存屏障,执行指令时,就不会对其两侧的相关指令进行重排
内存屏障会触发相关的冲刷处理器、刷新处理器动作,保证其两侧指令不会产生存储子系统重排序的现象
x86处理器下需要使用LOCK前缀指令或者sfence指令、mfence指令作为内存屏障
分类
按可见性保障分
加载屏障:刷新处理器缓存,即清空无效化队列
存储屏障:冲刷处理器缓存,即清空写缓冲器
按有序性保障分
获取屏障:读操作后插入,禁止该读和其之后的读写重排序,相当于在后续操作前,获取共享数据的所有权,所以叫获取屏障
释放屏障:写操作前插入,禁止该写和其之前的读写重排序,相当于在后续操作前,释放共享数据的所有权,所以叫释放屏障
3.7 volatile
volatile:英文意思为不稳定,表示其修饰的变量不稳定,容易被其他线程修改,因此该变量读操作每次都需要重新从高速缓存获取放入寄存器,写操作必须及时从寄存器放入高速缓存中
volatile不能与final一起使用
被称为轻量级锁,其作用与锁的作用有相同的地方:保证可见性和有序性,在原子性方面它仅能保障写volatile变量操作的原子性,但没有锁的排他性,不会引起上下文切换,所以说是轻量级的锁
3.7.1 volatile保障原子性
注意:写操作和赋值操作并不是一种操作
volatile关键字只能够保障对long/double型变量的写操作具有原子性,而不能保证volatile变量的赋值操作具有原子性
例如count1 = count2 + 1;,即使用volatile修饰count1,该操作也不一定是原子操作,如果count2是共享变量,就不是,如果是局部变量就是
因为如果共享,那么其他线程可能在赋值期间,更新count2的值,也就是第一步读取count2,第二步+1,第三步将结果赋值给count1,第三步是原子的,第一步执行时,其他线程也能执行这个第一步
例如volatile Map aMap = new HashMap();就具备原子性
objRef = allocate(HashMap.class); // 子操作①:分配对象所需的存储空间
invokeConstructor(objRef); // 子操作②:初始化objRef引用的对象
aMap = objRef; // 子操作③:将对象引用写入变量aMap
虽然volatile关键字仅保障其中的子操作③是一个原子操作,但是由于子操作①和子操作②仅涉及局部变量而未涉及共享变量,因此对变量aMap的赋值操作仍然是一个原子操作。
访问同一个volatile变量的线程被称为同步在这个变量之上的线程
3.7.2 保障有序性和可见性
可见性
volatile如果修饰引用类型或数组,不保证他们的属性或者元素值可见,因为对volatile变量的属性或元素修改和读取时,前后不会加内存屏障,如果要使对数组元素的读写也能触发volatile的作用,应使用AtomicIntegerArray、AtomicLongArray和AtomicReferenceArray
只能读到相对新值,无法读到最新值,因为使用读到的值的过程中,其他线程可以对其修改
有序性
volatile写,以及其之前的所有操作,被其后续的volatile读以及其之后的所有操作看来,是有序的
通常写的时候先写其他共享变量,最后写volatile变量,然后读的时候先读volatile变量,再读其他共享变量,这样就能保证有序性
volatile关键字也可以被看作给JIT编译器的一个提示,它相当于告诉JIT编译器相应变量的值可能被其他处理器更改,从而使JIT编译器不会对相应代码做出一些优化而导致可见性问题
3.7.3 volatile变量的开销
写后产生的冲刷处理器和读前产生的刷新处理器
3.7.4 volatile应用场景
保证可见性

案例一:我们想动态修改使用的负载均衡器,可以另启一个配置管理线程,修改存放负载均衡器的属性值,为了让原线程能读取到配置管理线程修改后的值,就需要将负载均衡器属性设置为volatile
案例二:负载均衡器探测下游节点是否在线,会另启一个心跳线程,定时检测下游部件各个节点的状态,一旦发现状态不正确,就将下游节点对象的online属性进行修改,表示该节点不再可用,而主线程中为了读取到下游节点最新状态,就需要将online属性设置为volatile
替代多个线程对多个共享数据进行更新场景下使用的锁

多个线程共享一组可变状态变量的时候,通常我们需要使用锁来保障对这些变量的更新操作的原子性,例如A线程中需要使用这组状态,而B线程中修改这组状态,很可能B刚修改完第一个、第二个状态,而第三个状态还未修改,此时A读到的第一个状态和第二个状态是新的值,而第三个状态是原值,那么A中可能就会进入错误的逻辑
可以把这一组可变状态变量封装成一个对象,然后在B线程中创建一个新对象,然后将新对象赋值给volatile变量,这样A中就没发读到更新一半的值了
实现简易版读写锁

//保障原子性:因为volatile修饰的变量本身就读写都具备原子性,因此满足原子性的要求//保障可见性:volatile修饰的变量本身就满足可见性//保障有序性:volatile修饰的变量本身就满足有序性public class Counter { private volatile count; public long vaule() { return count; } public void increment() { synchronized (this) { count++; } }}
1
3.8 正确实现多线程下延迟初始化的单例
DCL(Double checked Locking)单例

public class DCLSingleton { //3.为了解决有序性引发的问题,使用volatile修饰,禁止instance写操作和其前面的读写操作重排序,从而保障了线程安全,同时避免了第一个if中instance看不见写线程的相对新值,而导致必须进入锁中判断,从而提升了性能 private static volatile DCLSingleton instance = null; private DCLSingleton() { } public static DCLSingleton getInstance() { //2. 提前判断instance是否为null,减少锁的开销 //a. 但这样做后,导致instance第一次读和后面同步块中代码并不具备原子性,也不具备可见性 //即第一个if判断可以和同步块中代码穿插执行,且同步块中对instance变量的写无法令第一个if判断可见 //但这都不会影响线程安全,因为当进入同步块中后,会再次进行if判断 //b. 但由于也不保证有序性,即第一个if中看到的同步块中代码顺序可能不正确,如果同步块中instance = new DCLSingleton();代码重排序了,导致写操作先于初始化操作,那么就导致第一个if中读到一个未初始化的值,如果将该值返回给外界就会有问题 if (instance == null) { //1. 对共享变量的访问都应该加锁,因此使用synchronized对其加锁 synchronized (DCLSingleton.class) { if (null == instance) { instance = new DCLSingleton(); } } } return instance; }}
1
基于内部类:这样即使StaticHolderSingleton被初始化,例如访问其某个静态变量或调用静态方法,也不会导致StaticHolderSingleton对象的创建,除非InstanceHolder初始化,或调用getInstance方法才会创建对象

public class StaticHolderSingleton { //私有化构造器 private StaticHolderSingleton() { Debug.info(“StaticHolderSingleton inited.”); } //使用静态成员 private static class InstanceHolder { //保存外部类的唯一实例 final static StaticHolderSingleton INSTANCE = new StaticHolderSingleton(); } public static StaticHolderSingleton getInstance() { Debug.info(“getInstance invoked.”); return InstanceHolder.INSTANCE; }}
1
最佳方案:枚举,外部类被初始化时,不会导致内部类中对象创建

public class EnumBasedSingletonExample { public static enum Singleton { INSTANCE; // 私有构造器 Singleton() { Debug.info(“Singleton inited.”); } }}
1
3.9 CAS与原子变量
3.9.1 CAS
CAS是一种cpu指令,x86处理器中是cmpxchg指令,可以使单个共享变量的check-then-act操作具备原子性,且不用加锁,所谓原子,指对单个共享变量的CAS操作不能交错执行,原子性由CPU保证

CAS指令逻辑类似下面代码

boolean compareAndSwap(Variable V, Object A, Object B){ // check:检查变量值是否被其他线程修改过 f (A == V.get()){ // act:更新变量值 V.set(B); // 更新成功 return true; } // 变量值已被其他线程修改,更新失败 return false; }
1
我们完全可以利用CAS,令对单个共享变量的read-modify-write操作也具备原子性,例如count++我们可以改造为如下代码

//原子性:如下代码具备其实可以看作具备原子性,因为即使两个线程中该段代码交错执行,比如另一个线程读到了count的旧值,但到后面compareAndSwap处就会报错,再次进入循环,相当于不会对线程安全有任何影响,避免了原子性造成的线程不安全//可见性:再通过使用volatile修饰count,就避免了可见性造成的线程不安全//有序性:当涉及到多个共享变量更新或单个共享变量的多次更新,才会造成有序性的问题,因此此处不会因为有序性影响线程安全//综上,通过CAS+volatile可以保证单个共享变量读写的线程安全do { //read oldValue = count; //modify newValue = oldValue + 1; //write} while (compareAndSwap(count,oldValue,newValue));
1
CAS只能保障单个共享变量check-then-act和read-modify-write操作原子性

因为volatile可以保证有序性和可见性,因此CAS+volatile可以保证单个共享变量check-then-act和read-modify-write操作的线程安全

CAS方法通常需要传入如下参数:要修改的对象、要修改的属性在该对象中的偏移量、当前值、更新前的值、更新后的值

利用CAS实现线程安全的计数器:注意CAS操作不保证更新的可见性

package io.github.viscent.mtia.ch3;import io.github.viscent.mtia.util.Debug;import io.github.viscent.mtia.util.Tools;import java.util.HashSet;import java.util.Set;import java.util.concurrent.atomic.AtomicLongFieldUpdater;public class CASBasedCounter { //由于CAS并不保证count的可见性,因此需要加volatile private volatile long count; //AtomicLongFieldUpdater对象有方法compareAndSet,就是CAS操作 //指定AtomicLongFieldUpdater对象的CAS操作用于更新CASBasedCounter类的count属性 private final AtomicLongFieldUpdater fieldUpdater = AtomicLongFieldUpdater.newUpdater(CASBasedCounter.class,“count”); public void increment() { long oldValue; long newValue; //如果失败需要重试 do { // 读取共享变量当前值 oldValue = count; // 计算共享变量的新值 newValue = oldValue + 1; } while ( //使用CAS操作对属性进行更新 !compareAndSwap(oldValue, newValue) ); } //CAS操作交给Java提供的AtomicLongFieldUpdater类完成 private boolean compareAndSwap(long oldValue, long newValue) { boolean isOK = fieldUpdater.compareAndSet(this, oldValue, newValue); return isOK; } public static void main(String[] args) throws Exception { final CASBasedCounter counter = new CASBasedCounter(); Thread t; Set threads = new HashSet(); //模拟多个线程同时更新count属性值 for (int i = 0; i < 10000; i++) { t = new Thread(new Runnable() { @Override public void run() { Tools.randomPause(50); counter.increment(); } }); threads.add(t); } //启动并等待指定的线程结束 Tools.startAndWaitTerminated(threads); //打印的结果中显示,启动了几个线程,最后该值就为几,按结果看来线程安全 Debug.info(“final count:” + String.valueOf(counter.count)); }}
1
3.9.2 原子变量类
原子变量类保障了其对应类型的单个共享变量的check-then-act和read-modify-write操作的线程安全,例如AtomicLong保障了单个Long类型的共享变量的check-then-act和read-modify-write操作线程安全

内部通常使用CAS+volatile保证线程安全

volatile修饰内部的共享变量,保证可见性
由于是单个共享变量读写,只要保证可见性就不会造成有序性问题
利用CAS实现更新共享变量变量的逻辑,保证具备原子性
AtomicLong、AtomicInteger:保障单个long和int类型共享变量check-then-act和read-modify-write操作的线程安全

get:获取当前值
getAndIncrement:原子的i++
getAndDecrement:原子的i–
incrementAndGet:原子的++i
decrementAndGet:原子的–i
set:设置为指定值
AtomicBoolean:保障单个Boolean类型共享变量check-then-act和read-modify-write操作的线程安全

compareAndSet:先判断传入值和当前值是否相同,如果相同则更新并返回true,不相同则返回false

private final AtomicBoolean initializating = new AtomicBoolean(false);public void init() { //使用AtomicBoolean的CAS操作确保多次调用init方法时,其内业务逻辑只执行一次,且避免使用锁 if (initializating.compareAndSet(false, true)) { //业务逻辑 }}//如果不适用AtomicBoolean,就只能使用锁实现public void init() { //由于涉及到共享变量的读写操作,为保证线程安全必须加锁 synchronized (this) { if (initInProgress) { return; } initInProgress = true; } //业务逻辑}
1
AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray:保障数组中的属性于元素check-then-act和read-modify-write操作的线程安全,其内部使用CAS修改数组中元素,并在修改成功后加入volatile语义,保证数组元素的check-then-act和read-modify-write操作的原子性以及可见性

AtomicReference:保障单个引用类型共享变量check-then-act和read-modify-write操作的线程安全

AtomicStampedReference:如果ABA问题不可接受,使用这个类来避免ABA问题,但从实际应用角度来说,大部分ABA问题是可以接受的,例如使用AtomicLong统计成功失败次数,假设当前值为5,两个线程成功调用incrementAndGet,另一个线程失败调用decrementAndGet,我们期望的是,当三个线程结束后,值为6,此时假设一个成功线程先执行完毕,值被更新为6,然后一个失败线程执行完毕,更新为5,此时最后一个成功线程,其实就存在了ABA问题,因为它提供的旧值其实和第一个线程提供的旧值,是同一时间读到的,但该值其实已经经历过+1又-1,但虽然发生了ABA问题,此处更新成功并不会影响线程安全,因为成功后结果是6,正是我们想要的

AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater:字段更新器,是对CAS操作的封装,原子变量类中使用的CAS操作可以通过他们完成

Unsafe:最底层的对CAS操作的封装

3.10 对象的发布与逸出
3.10.1 发布
发布:指使对象能够被其作用域之外的代码中使用
当对象发布后,就有可能被多个线程使用了,就变成了共享变量,就可能造成线程安全问题
3.10.2 发布的形式
非private方法中使用private属性

可以理解为对象经历了两次发布,被发布的对象为new HashMap创建出的对象

第一次为registry = new HashMap,作用域从此行代码扩大到了Example类中

第二次为在public的someService方法中使用registry,作用域从整个类中扩大到了所有调用someService的代码块中

也就是原本只有在Example类中启动多个线程才能共享new HashMap,现在只需在外部启动多个线程调用同一个Example对象的someService方法,就能共享new HashMap

public class Example { private Map<String, Integer> registry = new HashMap<String, Integer>(); public void someService(String in) { // 访问registry }}
1
将对象引用存储到public变量中:被发布的对象为new HashMap,作用域从此行代码扩大到了所有使用registry的代码块中

public Map<String, Integer> registry=new HashMap<String, Integer>();
1
在非private方法中返回一个private变量引用的对象:例如get方法,就将成员变量对应的对象进行了发布

创建内部类

被发布的对象为startTask所在的对象this

该对象原本作用域为startTask方法中,被发布后,作用域扩大到了该方法创建的内部类中

那么如果在这个内部类中,又通过public方法使用了this,那么相当于又进行了一次发布,作用域从内部类中扩大到了使用该public方法的代码中

public void startTask(final Object task) { //发布:将this发布到了实现了Runnable接口的内部类中 Runnable runnable = new Runnable() { @Override public void run() { //如果此处访问外部类对应的this,相当于又进行了一次发布 } }; //start内部会使用public的run方法 Thread t = new Thread(runnable); t.start(); Thread t1 = new Thread(runnable); t1.start();}
1
通过方法调用将对象传递给外部方法:例如set方法就将对象发布到了set方法体中,然后后续又用成员变量接收这个对象,就相当于将对象发布到了所有能使用这个成员变量处

外部方法:指相对于某个类而言其他类的方法或者该类的可覆盖方法

因为如果不是外部方法,那么作用域不会变

对象原本作用域为创建代码行,发布后,扩大到了setMap方法中

Map registry=new HashMap();a.setMap(registry);
1
3.10.3 static
JVM类加载过程分为几个阶段,分别是加载、验证、准备、解析和初始化,初始化指调用static代码块、为所有static变量赋初值

只有如下几种情况才会发生初始化

new、getstatic、putstatic或invokestatic
反射
初始化子类,父类也会被初始化
JVM启动时,初始化main方法所在类
MethodHandle相关
当多个线程执行的操作触发同一个类的初始化时,JVM层面会保证初始化只由一个线程完成,且初始化结果(所有static变量的初始值以及static代码块中内容)对所有线程可见

因此当一个线程访问一个static变量时,一定能保证访问到的至少是初始值而不是默认值,且static变量引用的对象一定也初始化完毕,因为该对象初始化未完成,static所在类的初始化不算完成

由于只能保证类初始化结果对其他线程可见,因此如果后续有线程为static变量赋值,是无法保证该赋值操作对其他线程可见,也无法保证static变量引用的新对象一定初始化完毕

//1. 可以保证后面创建的这个对象已经初始化完毕private static Example example = new Example();//2. 这种就不能保证了,因为初始化时候根本不会执行new Example这行代码private static Example example;private static Example setExample(){ example = new Example();}
1
3.10.4 final
创建对象时,会对对象进行初始化,对象的初始化指调用代码块、为所有非static变量赋初值
JVM要求,JIT编译时,需保证不将final字段的初始化、final指向对象的初始化的相关指令重排序到其所在构造器之后,而非final字段初始化是有可能重排序到构造器之后的
之后JIT会在final变量的写操作后加入StoreStore屏障,保证cpu不会进行指令重排序,存储子系统也不会进行内存重排序
因此只要一个线程只要能看见其他线程发布的包含final变量的对象(final无法保证其所在对象对其他线程可见),就一定能读到该对象中final变量的初始值,且此时final变量引用的对象一定也初始化完毕,而由于final修饰的变量无法被修改,因此读取到的final的初始值也是最新值
final和volatile不能同时修饰一个成员变量原因
final不能保证包含final变量的对象的可见性,volatile同样不能
final和volatile都能保证其修饰的变量的写操作不能与其他指令重排序
那么实际上来说,除了final修饰的变量不能修改这个性质来说,volatile完全可以替代final,而volatile本身的语义就是为了变量的修改存在的
因此要么使用final要么使用volatile,根本没必要同时使用,同时使用不会提供额外作用
3.10.5 安全发布与逸出
安全发布:指对象以一种线程安全的方式被发布,此处线程安全指:发布的对象可见,且其已经初始化完成,而不是初始化一半

想让多个线程共享一个对象,通常做法都是先将新创建的对象通过set方法或初始化,发布到对应的成员变量中,然后在各个线程中使用这个成员变量,从而将对象再次发布出去

安全发布对象:就是保障该对象的引用对其他线程可见前,发布线程对该对象所执行的操作(即使这些操作并没有在临界区中执行)对其他线程来说是可见的且有序的,其实就是创建需要被多个线程共享的对象时,应该如何将该对象发布出去,通常都是通过将这个对象给一个特殊关键词修饰的引用来完成发布

使用static关键字修饰被发布对象的引用:只能将类初始化中创建的对象安全发布
使用final关键字修饰被发布的对象的引用:只能将对象初始化中创建的对象安全发布
使用volatile关键字修饰该对象的引用:可以随时通过写对应属性的值(例如set方法)来安全发布新的对象
使用AtomicReference类型的变量来引用该对象
对访问该对象的代码进行加锁:可以随时通过写对应属性的值(例如set方法)来安全发布新的对象
逸出:指一个对象的发布出现我们不期望的结果或者对象发布本身不是我们所期望的时候,即对象逸出一定会造成发布的对象未初始化完成,因此根本无法安全发布逸出的对象

在构造器中将this赋值给一个共享变量

public class NonSafeObjPublish { private NonSafeObjPublish(Map<String, String> objectState) { //此时其他线程访问OutterClass.sharedVar,获取到的就是一个未初始化完成的NonSafeObjPublish对象 OutterClass.sharedVar = this; }}
1
在构造器中将this作为方法参数传递给其他方法

public class NonSafeObjPublish { private NonSafeObjPublish(Map<String, String> objectState) { OutterClass.sharedMethod(this); }}
1
在构造器中启动基于匿名类的线程

public class NonSafeObjPublish { private Map<String, String> objectState; private NonSafeObjPublish(Map<String, String> objectState) { this.objectState = objectState; new Thread() { @Override public void run() { //NonSafeObjPublish对象逸出了,该对象一定没初始化完成时就被发布到了线程中 NonSafeObjPublish obj = NonSafeObjPublish.this; } }.start(); }}
1
4 玩转线程
4.1 分治
基于数据的分割:分为多个较小数据规模,并发执行
基于任务的分割:分为多个步骤,并发执行
4.2 基于数据的分割实现并发
会产生的问题
工作线程数量的合理设置:线程数量过多会增加如下开销,如果这些增加的开销无法被子输入规模减小所带来的好处抵消,继续增加线程就不会使任务处理更快
上下文切换
线程创建与销毁
建立网络连接
锁的争用
工作线程出现异常时的处理:其中一个工作者线程出现问题,这个线程以及其他线程,是应该重新开始还是停止
原始输入规模未知时,如何分解任务:可以采用批处理的方式对原始输入进行分解,即聚集了一批数据之后再将这些数据指派给工作者线程进行处理
会造成程序复杂度增加:多线程下载时,需要保证多个线程按原始文件顺序写入新文件
4.3 基于任务的分割实现并发
4.3.1 按任务的资源消耗属性分割
可划分为CPU密集型 (CPU-intensive)任务和I/O密集型 (I/O-intensive)任务
cpu密集型:加密、解密
IO密集型:磁盘、网络读写
可以将混合型任务进一步分解为CPU密集型和I/O密集型这两种子任务,并使用专门的工作者线程来负责这些子任务的执行以提高并发性
基于数据分割处理IO密集型任务的问题:例如多线程读取多个日志文件并进行分析
增加程序复杂性:因为日志有顺序,如果后面的日志先被读取,那么可能最终分析结果和想要的不同
由于I/O资源争用增加而减低I/O效率:读一个文件时是顺序读,但如果多线程读取,会转为随机读,反而会降低文件读取的效率
处理器时间的浪费:线程在等待磁盘返回数据的期间,该线程是处于暂停(WAITING)状态的,此时cpu可能去执行其他线程,当前线程中不会有任何计算
为什么按任务的资源消耗属性分割后会提升效率
因为混合型任务中,当读取文件时,该线程会暂停,假设为1s,此时该任务后续的内容也无法得到执行,当读取到文件内容后,任务后续会执行,假设后续还需1s,那么每处理一条数据的时间为1+1 = 2s
将他们分成两个线程,这样读取文件的线程,虽然还是需要等待,1s,但之后不对数据进行处理,只是放入一个队列中,0s,然后处理CPU密集的任务的线程,从这个队列中获取任务进行处理,还是1s,这样,其实当IO线程每读取到一条数据,cpu就线程就能开始处理,最终相当于,每条数据的处理就变为1s,总体时间缩短一半
4.3.2 按任务的资源消耗属性分割的问题
导致程序的复杂性增加
增加额外的处理器时间消耗:例如上下文切换、额外处理逻辑
多线程程序未必比相应的单线程程序快
考虑从单线程程序向多线程程序“进化”,而不是直接多线程版本
4.4 合理设置线程数
Amdahl’s定律:当CPU足够多,那么部分任务并发处理比所有任务串行处理,最高提速 = 1/P
P为只能串行处理的任务(无法并发)占全部任务串行执行的总时间的百分比
因为最多就是并行部分时间无限快,只需完成单线程任务即可,因此最高提速1/P
线程数设置原则
Nthreads = Ncpu * Ucpu *(1+WT/ST)
Ncpu:CPU个数
Ucpu:程序允许的CPU最大使用率,因为需要留一些CPU给系统或其他应用
WT:程序花费在等待(例如等待I/O操作结果)上的时长
ST:程序实际占用处理器执行计算的时长(其实就是总时长)
CPU密集型(WT/ST为0)任务应启动线程数
根据公式,线程数基本等同于Ncpu
CPU密集型线程也可能由于某些原因(比如缺页中断/Page Fault)而被切出,为了避免处理器资源浪费,设置线程数设置为N cpu +1
IO密集型(WT/ST为1)任务应启动线程数
根据公式,等待时间越长,线程数应设置的越多
这其实是因为等待的时间越长,那么就越可能出现CPU空闲,因此为了充分利用CPU,应该将线程数增加
但实际上线程增加后,反而会导致等待时间更长
因此通常优先考虑1个线程,如果确实不够,可以逐渐增加,向2×Ncpu 靠近
WT/ST可以自己加日志得到,或根据jvisualvm提供的监控数据计算
5 线程间协作
5.1 wait/notify
应用场景:程序要执行的操作(目标动作)需要满足一定的条件(保护条件)才能执行,当保护条件不满足,先暂停该线程,直到其所需的保护条件成立时再将其唤醒

5.1.1 wait/notify的作用与用法
wait和notify方法在Object类中定义

执行线程必须获取someObject对应的内部锁,才能调用someObject的wait和notify方法

等待线程:wait的执行线程

通知线程:notify的执行线程

对象someObject上的等待线程

因执行someObject.wait()而被暂停的线程
someObject上可能有多个等待线程
JVM会为每个对象维护一个等待集(Wait Set)的队列,存放该对象上的等待线程,还会维护一个入口集(Entry Set)用于存储申请该对象内部锁的线程
someObject.wait

将当前线程加入等待集
释放someObject对应的内部锁
暂停当前线程(线程真正挂起,并变为Waiting状态)
尝试重新申请someObject对应的内部锁
someObject.notify

从等待集中获取一个线程
将该线程从等待集中转入一个JVM为该对象维护的EntryList队列,如果EntryList队列不为空,从等待集中转入入口集
monitorexit

如果EntryList为空,从入口集中取出一个线程放入EntryList
释放someObject对应的内部锁
从EntryList中取出一个线程,将其唤醒
此时wait的执行线程继续执行步骤3,尝试申请someObject对应的内部锁
someObject.notifyAll:唤醒someObject上的所有等待线程

wait实现等待的模版代码

// 使用锁// 1. 想调用someObject.wait,必须获取someObject的锁// 2. 对保护条件的判断以及目标动作的执行,必须是原子操作,不然在执行doAction时,如果其他线程又修改了保护条件,就会出现线程安全问题synchronized(someObject){ // 使用while // 1. 防止被唤醒到其再次获取someObject对应的锁期间内,其他线程抢先获得锁并更新了保护条件相关共享变量而导致保护条件再次不成立 while(保护条件不成立){ someObject.wait(); } // 执行目标动作 doAction();}
1
notify实现通知的模版代码

// 使用锁// 1. 想调用someObject.notify,必须获取someObject的锁synchronized(someObject){ // 更新等待线程的保护条件涉及的共享变量 updateSharedState(); // 不会释放someObject对应的内部锁,临界区结束才会释放内部锁 someObject.notify();}
1
someObject.wait(long timeout)

被暂停的等待线程在这个时间内没有被其他线程唤醒,那么Java虚拟机会自动唤醒该线程
用于实现场景:不一直等待保护条件成立,而是只等待指定时间,指定时间后,无论保护条件是否成立,都要继续执行代码
long start = System.currentTimeMillis();long waitTime;long now;synchronized(someObject){ while(保护条件不成立){ now = System.currentTimeMillis(); // 计算剩余等待时间 waitTime = timeOut - (now - start); if (waitTime <= 0) { // 等待超时退出循环 break; } someObject.wait(waitTime); } //需要再次判断保护条件,如果成立,说明是被人唤醒的,如果不成立,说明是超时导致的 if (保护条件成立) { // 执行目标动作 doAction(); }else{ //等待超时的处理 }}
1
5.1.2 wait/notify的开销及问题
过早唤醒:jdk1.5后使用Condition接口解决该问题
线程W1、W2、W3都调用了someObject的wait方法,但W1和W2使用保护条件1,W3使用保护条件2,此时线程N1使保护条件1成立,并调用notifyAll方法, 本意是唤醒W1、W2,但此时实际上将W3也唤醒,而W3被唤醒后,由于不满足保护条件2,再次wait,我们称W3被过早唤醒
过早唤醒会造成资源浪费
信号丢失:属于代码逻辑错误
等待线程调用wait时,没判断保护条件,导致保护条件成立时,也调用了wait,那么通知线程如果在wait前就调用了notify,就会导致该等待线程永远无法被唤醒
调用Object.notifyAll()的地方却调用了Object.notify()
通知线程更新的共享变量对应的那个保护条件并不是唤醒的等待线程的保护条件
欺骗性唤醒:将对保护条件的判断和Object.wait()调用行放在一个循环语句之中
等待线程也可能在没有其他任何线程执行Object.notify()/notifyAll()的情况下被唤醒
上下文切换问题
wait/notify的使用可能导致较多的上下文切换
5.1.3 Object.notify()/notifyAll()的选用
优先使用Object.notifyAll()以保障正确性
使用Object.notify()替代Object.notifyAll()需要满足以下两个条件
一次通知仅需要唤醒至多一个线程
相应对象的等待集中仅包含同质等待线程,所谓同质等待线程
同质等待线程:使用同一个保护条件,且在Object.wait()调用返回之后的处理逻辑一致的线程
通常是使用同一个Runnable接口实例创建的不同线程,或同一个Thread子类new出的多个实例
5.1.4 wait/notify与Thread.join()
应用场景:目标线程.join可以实现其执行线程等待目标线程结束后再继续执行,如果join中带参数,可以实现等待超过指定时间目标线程还未结束,执行线程自动继续执行
Thread.join内部是通过wait和notify实现的,可参见其源码
目标线程.join方法中会调用其wait方法,从而导致执行线程进入等待
JVM会在目标线程run方法结束后,调用其notifyAll方法,唤醒所有等待线程
5.2 LockSupport
LockSupport.park():使执行线程挂起
LockSupport.unpark(Thread thread):唤醒指定线程
其实是一个C++实现的方法,JVM中Object.wait就是使用park对应的C++方法实现的挂起线程,而锁退出时,就是使用unpark对应的C++方法实现的唤醒入口集中的线程
AQS内部使用了LockSupport
5.3 Condition(条件变量)
条件变量 (Condition Variable):Condition实例,也称为条件队列,其内部维护了一个用于存储等待线程的队列,并通过LockSupport的park和unpark实现线程的等待和唤醒

应用场景:wait/notify过于底层,Condition接口可作为wait/notify的替代品来实现等待/通知

解决过早唤醒问题

设cond1和cond2是两个不同的Condition实例
cond1.await():将其执行线程暂停并存入cond1的等待队列
cond1.signal():将cond1的等待队列中的一个任意线程被唤醒
cond1.signalAll():将cond1的等待队列中的所有线程被唤醒,而cond2的等待队列中的任何一个等待线程不受此影响
让使用不同保护条件的等待线程调用不同的条件变量的await方法来实现其等待
通知线程在更新了共享变量之后,仅调用涉及了这些共享变量的保护条件所对应的条件变量的signal/signalAll方法来实现通知
解决Object.wait (long)无法区分其返回是由于等待超时还是被通知线程唤醒等问题

Condition.awaitUntil(Date deadline):返回值true表示进行的等待尚未达到最后期限,返回false表示超时
创建Condition实例:Lock.newCondition()

Condition.await()/signal()要求其执行线程持有创建该Condition实例的显式锁(Lock)

Condition实现等待的模版代码

private final Lock lock = new ReentrantLock();private final Condition condition = lock.newCondition();lock.lock();try { while(保护条件不成立){ condition.await(); } // 执行目标动作 doAction();} finally { lock.unlock();}
1
Condition实现通知的模版代码

lock.lock();try { // 更新共享变量 changeState(); condition.signal();} finally { lock.unlock();}
1
5.4 CountDownLatch(倒计时协调器)
应用场景:一个(或者多个)线程等待其他线程完成一组特定的操作之后才继续运行,这组操作被称为先决操作
内部通过AQS实现
CountDownLatch.await():相当于一个受保护方法
保护条件:计数器值为0,计数器的初始值在CountDownLatch的构造参数中指定
目标操作:是一个空操作
因此当计数器值不为0时CountDownLatch.await()的执行线程会被暂停
这些线程就被称为相应CountDownLatch上的等待线程
CountDownLatch.countDown():相当于一个通知方法
它会在计数器值达到0的时候唤醒相应实例上的所有等待线程
总应该放在finally块中,防止因异常未被调用
一个CountDownLatch实例只能够实现一次等待和唤醒
当计数器为0后,调用CountDownLatch.countDown()也不会抛出异常,CountDownLatch.await()也不会再暂停线程
使用CountDownLatch实现等待/通知的时候调用await、countDown方法都无须加锁,因为其内部已经实现了这些逻辑
CountDownLatch.await(long, TimeUnit):允许指定一个超时时间,在该时间内如果相应CountDownLatch实例的计数器值仍然未达到0,那么所有执行该实例的await方法的线程都会被唤醒,该方法的返回值可用于区分其返回是否是由于等待超时
5.5 CyclicBarrier(栅栏)
应用场景:多个线程需要相互等待对方执行到代码中的某个地方(集合点),这时这些线程才能够继续执行

如果代码对CyclicBarrier.await()调用不是放在一个循环之中(迭代),并且使用CyclicBarrier的目的也不是为了模拟高并发操作,那么此时对CyclicBarrier的使用可能是一种滥用,可以使用Thread.join()或者CountDownLatch来替代

参与方:使用CyclicBarrier.await()的线程

CyclicBarrier构造器

CyclicBarrier(int parties, Runnable barrierAction)CyclicBarrier(int parties)
1
CyclicBarrier.await()

除最后一个线程外的任何参与方执行CyclicBarrier.await()都会导致该线程被暂停
最后一个线程执行CyclicBarrier.await()会使得使用相应CyclicBarrier实例的其他所有参与方被唤醒,而最后一个线程自身并不会被暂停
Cyclic表示CyclicBarrier实例是可以重复使用的

所有参与方被唤醒的时候,任何线程再次执行CyclicBarrier.await()又会被暂停,直到这些线程中的最后一个线程执行了CyclicBarrier.await()
barrierAction会被最后一个线程执行CyclicBarrier.await方法时执行,该任务执行结束后其他等待线程才会被唤醒

实现原理

内部通过Condition实现
保护条件:当前分代内,尚未执行await方法的参与方个数(parties)为0
当parties不为0时
CyclicBarrier.await()会使相应实例的parties值减少1
调用Condition.await进入等待
当parties值变为0(最后一个线程进入)
barrierAction.run()
重新创建一个分代对象,表示进入下一代
更新parties变为初始值
调用Condition.signalAll()来唤醒所有等待线程
5.6 生产者—消费者模式
生产者(Producer):生产(创建)产品(Product)
产品:既可以是数据,也可以是任务
消费者(Consumer):消费生产者所生产的产品
消费:对产品所代表的数据进行加工处理或者执行产品所代表的任务
生产者和消费者并发地运行在各自的线程之中的,使原本串行的处理并发化
生产者和消费者间需要通过一个传输通道(Channel)传递产品
生产者每生产一个产品就将其放入传输通道
消费者则不断地从传输通道中取出产品进行消费
生产者将产品传入传输通道,消费者再从传输通道中取产品,这个过程相当于发布产品,必须保证其线程安全,因此传输通道通常使用线程安全的队列来实现
传输通道还能起到平衡生产者和消费者处理能力的作用
将产品存入传输通道相比消费者处理能力快很多
消费者从传输通道取出产品相比生产者的处理能力快很多
5.6.1 BlockingQueue(阻塞队列)
阻塞方法:能导致其执行线程被暂停(生命周期状态为WAITING或者BLOCKED)的方法,阻塞方法会导致上下文切换
常见阻塞方法
InputStream.read()
ReentrantLock.lock()
申请内部锁
BlockingQueue接口:阻塞队列
put:队列满后再put会阻塞
take:队列空后再take会阻塞
阻塞队列的分类:按存储空间的容量
有界队列
可以指定队列容量
为防止当产品的生产速率大于消费速率,引起队列中的产品积压,从而进一步引起占用内存空间变大,就需要使用有界阻塞队列作为传输通道
使用有界队列作为传输通道还可以达到给消费者机会追上生产者的机会
无界队列:容量为Integer.MAX_VALUE
BlockingQueue实现类
ArrayBlockingQueue:适合在生产者线程和消费者线程之间的并发程度较低的情况下使用
只能实现有界队列
put、take使用一个锁
可能导致锁的高争用(生产者和消费者争),进而导致更多的上下文切换
支持公平调度和非公平调度:即存在多个生产者和多个消费者时,哪个线程的put或take先执行
LinkedBlockingQueue:适合在生产者线程和消费者线程之间的并发程度比较大的情况下使用
可以实现有界队列和无界队列
put、take使用的不是一个锁,但因此内部使用AtomicInteger记录产品个数
可能导致AtomicInteger争用,进而导致额外的CPU开销
只支持非公平调度
SynchronousQueue:适合在消费者处理能力与生产者处理能力相差不大的情况下使用
相当于容量为0的队列,因此put总会阻塞
适合于在消费者处理能力与生产者处理能力相差不大的情况下使用,否则会导致更多的等待和上下文切换
支持公平调度和非公平调度
5.6.2 流量控制与信号量(Semaphore)
Semaphore相当于虚拟资源配额管理器,它可以用来控制同一时间内对虚拟资源的访问次数

Semaphore(int permits):创建Semaphore对象,初始配额为permits
Semaphore(int permits, boolean fair):默认采用非公平调度策略,也可以改为公平的调度策略,即因acquire暂停的线程哪个先被唤醒
Semaphore.acquire()
申请配额:申请成功会使配额减1并返回,如果配额不足执行线程会被暂停,并将该线程放入其内部维护的等待队列
Semaphore.release()
返还配额:会使配额加1,并唤醒等待队列中的任意一个等待线程
如果上来就调用该方法,会导致配额突破初始配额
Semaphore使用

// 申请一个配额semaphore.acquire(); try { // 访问虚拟资源:此处控制了同一时间不能有超过10个生产者线程生产产品 queue.put(product); } finally { // 返还一个配额 semaphore.release(); }
1
注意事项

Semaphore.acquire()和Semaphore.release()总是成对使用
Semaphore.release()应放在finally中,防止配额无法正确返还(类似锁泄漏)
5.6.3 管道:线程间的直接输出与输入
PipedOutputStream和PipedInputStream是生产者—消费者模式的一个具体例子

PipedOutputStream:生产者,生产的产品是字节形式的数据
PipedInputStream:消费者
传输通道:PipedInputStream内部定义的byte型数组
管道的使用

//1. 创建PipedInputStream和PipedOutputStream并建立关联,建立关联有两种方法//方法一:以in为参数创建PipedOutputStream实例final PipedInputStream in = new PipedInputStream();final PipedOutputStream pos = new PipedOutputStream(in);//方法二//final PipedOutputStream pos = new PipedOutputStream();//pos.connect(in);//或者这样写也可以//in.connnect(pos);//2. 将in和pos分别给到两个不同线程使用即可
1
注意事项

PipedOutputStream和PipedInputStream通常用于单生产者—单消费者的情形
如果多个生产者或者多个消费者,我们需要进一步保证字节流的顺序性,这会导致代码增加,锁争用
PipedOutputStream和PipedInputStream在同一线程中使用时,会导致死锁
输出异常的处理
生产者出现异常时,消费者无法知道,可能会一直等待
我们必须在生产者出现异常时立即关闭PipedOutputStream以通知消费者线程(catch块中而不是finally中),消费者线程发现器关闭后,才会解除等待
5.6.4 双缓冲与Exchanger
Exchanger是Java对双缓冲的模拟,通常可以用Exchanger作为单生产者—单消费者模式的传输通道
Exchanger.exchange(V x)
该方法会阻塞,直到另一个线程调用Exchanger.exchange(V)
参数x和返回值相当于两个缓冲区
消费者将x指定为一个空的或者已经使用过的缓冲区
生产者将x指定为一个已经填充完毕的缓冲区
其返回值为对方线程执行该方法时所指定的参数x的值
5.6.5 产品的粒度
例如使用一条日志作为产品,就说产品粒度较细,使用一批日志作为产品,就说产品粒度较粗
产品粒度过细会导致产品在传输通道上的移动次数过多,增加开销
5.6.6 再探线程与任务之间的关系
在单生产者—单消费者模式中,如果生产的是消费者线程需执行的任务(Runnable),那么实际上,一个消费者线程可以执行多个任务
所以说线程和任务可以是1:N的关系,这样做可以充分利用有限的线程资源,不必每个任务都启动一个线程执行
线程池的实现就是利用该思想,线程池的submit方法只是提交任务,多个任务可能由同一个消费者线程执行
5.7 线程中断机制
中断 (Interrupt):由一个线程(发起线程 Originator )发送给另外一个线程(目标线程 Target ),表示发起线程希望目标线程停止其正在执行的操作
JVM会为每个线程维护一个被称为中断标记 (Interrupt Status)的布尔型状态变量用于表示相应线程是否接收到了中断,为true表示相应线程收到了中断,所谓中断目标线程,就是调用该线程的interrupt()方法, 将其中断标记设置为true
相关方法
interrupt():将该线程的中断标记置为true,通常由发起线程调用
isInterrupted():获取该线程的中断标记值
Thread.interrupted ():获取执行线程的中断标记值,并将执行线程的中断标记值重新设为false,通常由目标线程调用
中断响应 :目标线程发现被中断后会怎么做
无影响
InputStream.read()
ReentrantLock.lock()
申请内部锁
抛出InterruptedException异常:这些方法的实现代码中,通常在阻塞前判断Thread.interrupted ()(返回中断标记并重置)的返回值,如果为true抛出异常
Object.wait()/wait(long)/wait(long, int)
Thread.join()/join(long)/join(long, int)
BlockingQueue.take() /put(E)
Lock.lockInterruptibly()
CountDownLatch.await()
CyclicBarrier.await()
Exchanger.exchange(V)
如果发起线程给目标线程发送中断的那一刻,目标线程已经由于执行了一些阻塞方法/操作而被暂停(生命周期状态为WAITING或者BLOCKED)了,那么此时JVM可能会设置目标线程的线程中断标记并将该线程唤醒,从而使目标线程被唤醒后继续执行的代码再次得到响应中断的机会,CountDownLatch.await()、CyclicBarrier.await()以及ReentrantLock.lockInterruptibly()这几个方法中,解除阻塞后还会判断中断状态,从而对中断进行响应
Java应用层对InterruptedException异常的正确处理
不捕获InterruptedException
捕获InterruptedException后进行一些处理再重新将该异常抛出
捕获InterruptedException后,再次中断当前线程:因为通常抛出InterruptedException的方法会将中断标记重置为false
吞没:捕获InterruptedException不进行任何处理。比较危险,只有在线程捕获到InterruptedException就可以终止的情况下才适用,其他情况下使用该策略可能导致目标线程无法被终止
5.8 线程停止
线程停止:让线程run方法返回

需要主动停止线程的场景

当线程已经没有需执行的任务了,应该及时停止线程以节省资源,例如线程池中如果发现一定时间内没有任务执行,就停止部分线程池中的线程
同质(线程的任务处理逻辑相同)工作者线程中的一个线程出现不可恢复的异常时,其他线程就没有必要继续运行下去了,应被停止
某些任务耗时太长,想停止该任务
停止线程的方案:停止标记+线程中断

只使用停止标记:如果此时该线程正处于暂停状态,那么由于无法被唤醒,从而导致该标记并没有效果,因此还需要发中断将其唤醒

只使用线程中断:中断标记可能会被目标线程所执行的某些方法清空,导致线程仍然无法停止

public void run() { Runnable task = null; try { for (; ; ) { task = channel.take(); try { //此处run方法是Runnable提供的,不能抛出InterruptedException,因此就存在可能在run中清除(吞没)线程中断标记 //此处消费的产品是一个任务 //其实作者想表述的是,只要是消费产品的代码,那么就是自己写的,就可能吞没线程中断标记,导致无法中断,因此我们根本不通过中断停止线程 task.run(); } //注意不是由于此处捕获异常导致吞没线程中断标记,因为InterruptedException根本抛不到这 catch (Throwable e) { e.printStackTrace(); } } } catch (InterruptedException e) { }}
1
5.8.1 生产者—消费者模式中的线程停止
生产者线程需要先于消费者线程停止,否则生产者所生产的产品会无法被处理

单生产者—单消费者模式:生产者线程在其终止(可以是自然终止)前往传输通道中存入一个特殊产品(毒药)作为消费者线程的线程停止标志,消费者线程发现取出的是这个特殊产品就退出run方法而终止,如果多个消费者,可以放入多个毒药,也可以在第一个消费者发现毒药后,终止自身并放入新的毒药用来停止其他消费者线程

多生产者—多消费者模式:停止标记+线程中断

//生产者中执行停止消费者public void shutdown() { //停止标志 inUse = false; final Thread t = workerThread; if (null != t) { //中断唤醒线程 t.interrupt(); }}public void run() { Runnable task = null; try { for (;😉 { // 线程不再被需要,且无待处理任务 // 线程不再被需要,且无待处理任务,但感觉这段代码有问题 //1. 如果在take处被中断,那么任务没完成也会被停止 //2. 如果在run处被中断,那么中断可能被吞没,然后又到了if处,但由于reservations.get()和channel.take()不是原子操作,导致最终还是进到了channel.take从而被阻塞,无法停止 //if (!inUse && reservations.get() <= 0) { //break; //} //感觉这样写才能正确停止线程,不过不能保证产品一定被消费完 if (!inUse) break; task = channel.take(); try { //消费产品,无论是否在这里被中断都不重要,因为不以其作为线程停止的标准 task.run(); } catch (Throwable e) { e.printStackTrace(); } // 使待处理任务数减少1 reservations.decrementAndGet(); } } catch (InterruptedException e) { //发现channel.take()抛出异常时停止线程,因为take抛出了InterruptedException,所以必须用try catch处理 workerThread = null; }}
1
6 保障线程安全的设计技术
第3章是从Java平台本身提供的机制的角度来介绍如何保障线程安全,本章从面向对象设计的角度出发介绍几种保障线程安全的常用技术,从而让我们在不使用锁的情况下保障线程安全,既避免锁的开销又提高了系统的并发,并简化了代码
6.1 Java运行时存储空间
栈空间不能被多个线程共享,堆和非堆中内容可以被多个线程共享
非堆空间:存放静态变量的引用,其指向的对象在堆中
固有(Inherent)的线程安全性:具有固有线程安全性的对象如何使用都是线程安全的
调用该对象的任何方法时都无须加锁
该对象自身的方法实现无须使用锁
线程对基本类型的局部变量和只能通过当前线程的局部变量访问到的对象的操作,具有固有的线程安全性。
6.2 无状态对象
对象是对操作(方法)和数据的封装,对象使用的数据就被称为该对象的状态,包括以下内容
存储在实例变量、静态变量中的数据
实例变量、静态变量引用的对象的实例变量、静态变量中的数据
方法中使用到的数据
无状态对象 (Stateless Object):如果一个类的同一个实例被多个线程共享并不会使这些线程存在共享状态(访问同一块数据),那么这个类及其任意一个实例就被称为无状态对象
不含任何实例变量、静态变量,或者其包含的静态变量都是只读的(常量)
其方法中不会访问共享变量
无状态对象具有固有的线程安全性,因为相当于根本不存在共享变量
Servlet的实例会被多个线程共享,因此为了有利于提高服务器的并发性通常将Servlet实例设计为无状态对象,因此Servlet类通常没有实例变量和静态变
6.3 不可变对象
有状态对象分类

状态可变对象
状态不可变对象:一经创建状态就保持不变
不可变对象具有固有的线程安全性,因为相当于共享变量根本无法修改

当不可变对象所建模的现实实体的状态发生变化时,系统只能通过创建新的不可变对象实例来进行反映,但如果对象较大,且状态变化频繁,会增加垃圾回收负担

//Candidate为不可变对象,因此不能直接调原来this.candidate指向的对象的set方法修改其状态,只能重新创建新的不可变对象,然后使用this.candidate指向新的不可变对象public void updateCandidate(final Candidate candidate) { this.candidate = candidate;}
1
不可变对象的实现

所在类使用final修饰:防止子类篡改其行为

所有字段用final修饰:防止被修改,更重要的是保证其修饰的字段的初始化安全,即final修饰的字段对其他线程可见前,其一定已初始化完毕,如果不这样做,就可能导致线程不安全,失去了固有的线程安全性

对象在初始化过程中没有逸出

不提供setter方法(提供也没用,因为属性已经是final修饰)

如果成员变量指向的是一个可变对象(集合、数组)

构造器和getter方法中,都应该进行防御性复制,防御性复制的本质是防止返回或传入的对象被其他线程修改,从而导致不可变对象自身的状态也被修改,只要能避免这点都算防御性复制

//防御性复制//方案一:使用Collections.unmodifiableSet:返回一个不能修改的Set,这样其他线程就无法修改这个集合中的内容了,从而不会导致不可变对象的状态改变//方案二:直接创建一个新的集合对象返回//但要注意,如果Set中存放(引用)的元素也是可变的,为了保证不可变对象的线程安全,应该对其内存放的元素也进行防御性复制final Set<Entry<String, BigObject>> readOnlyEntries = Collections .unmodifiableSet(registry.entrySet());
1
或者不提供getter方法而是使用迭代器模式防止状态被修改

//不提供不可变对象中,成员变量指向的可变对象(集合)的引用,而是返回一个只读的Iterator实例,这样既能读取到集合中的元素,又能不改变集合本身return ReadOnlyIterator.with(readOnlyEntries.iterator());
1
减少创建不可变对象所占用的内存空间:通过浅复制实现共享部分内存

private static HashMap<String, BigObject> createRegistry(BigImmutableObject prototype, String key, BigObject newValue) { // 从现有对象中复制(浅复制)字段 HashMap<String, BigObject> newRegistry = (HashMap<String, BigObject>) prototype.registry.clone(); // 仅更新需要更新的部分 newRegistry.put(key, newValue); return newRegistry;}
1
不可变对象使用场景

作为Map的key
被建模对象的状态变化不频繁,将该对象实现为不可变对象
同时对一组相关的数据进行写操作,将他们封装成一个不可变对象,防止一组数据不一致,因为压根不能改,同时使用volatile修饰不可变对象保证其可见性
如何理解String的不可变

String str = “I love java”;String str1 = str;//在对str进行了字符串替换替换之后,str1指向的字符串对象仍然没有发生变化。System.out.println(“after replace str:” + str.replace(“java”, “Java”));System.out.println(“after replace str1:” + str1);
1
不可变对象的状态通过反射的手段还是可以被改变的,对象的不可变性只是用来帮助程序员减少程序编写过程中出错的概率,这是不可变对象的初衷,而不是就是完全不可变

6.4 线程特有对象
线程特有对象:一个实例只能被一个线程访问的对象,相当于不共享变量了

Java中各线程通过ThreadLocal对象创建并访问各自的线程特有对象,因此ThreadLocal类型的变量也称为线程局部变量,其类型参数T为其关联的线程特有对象的类型

一个线程可以使用不同的ThreadLocal实例来创建并访问其不同的线程特有对象
多个线程使用同一个ThreadLocal实例所访问到的对象是类型T的不同实例,即这些线程各自的线程特有对象实例
Thread对象中,有一个threadLocals属性,该属性类似Map<ThreadLocal,Object>,当我们调用threadLocal.get(),得到的实际上是Thread.currentThread().threadLocals.get(threadLocal),因此多个线程上调用ThreadLocal的方法操作的一定不是同一个对象

ThreadLocal常用方法

get:获取线程特有对象
set:重新关联线程特有对象
initialValue:第一次调用get时,会通过该方法创建线程特有对象,之后会将其缓存
remove:删除关联关系
withInitial:Java8提供,和initialValue功能基本相同
static、static ThreadLocal、局部变量的区别

final static ThreadLocal SDF = new ThreadLocal() { @Override protected SimpleDateFormat initialValue() { return new SimpleDateFormat(“yyyy-MM-dd”); }};//final static SimpleDateFormat SDF = new SimpleDateFormat(“yyyy-MM-dd”);@Overrideprotected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { //1. 既保证了线程安全,又保证只为每个线程创建一次对象 final SimpleDateFormat sdf = SDF.get(); //2. 如果使用static修饰的变量,那么虽然SimpleDateFormat对象只创建一次,但该对象线程不安全,多线程中使用同一个对象结果会有问题 //final SimpleDateFormat sdf = SDF; //3. 如果使用局部变量,不再有线程安全问题,但每调用一次doGet方法就会创建一个该对象,浪费系统性能 //final SimpleDateFormat sdf = new SimpleDateFormat(“yyyy-MM-dd”); String strExpiryDate = req.getParameter(“expirtyDate”); sdf.parse(strExpiryDate); // 省略其他代码}
1
Servlet对象中如果使用static修饰的变量,相当于多线程使用同一个共享变量,会产生线程安全问题,如果使用static ThreadLocal修饰的变量,可以保证一个线程创建一次对象,如果使用doPost方法中创建的对象,其实相当于调用一次doPost方法就创建一个对象,ThreadLocal相当于增加了创建对象的次数,但保证了线程安全

线程局部变量通常是会被声明为某个类的静态变量,因为如果是实例变量,那么每创建该类的一个其实例就会创建一个新的ThreadLocal实例,导致当前线程中同一个类型的线程特有对象被多次创建,非常浪费,没有必要

JDK1.7中引入的ThreadLocalRandom其实功能就相当于ThreadLocal,解决了Random不是线程安全的问题

注意事项

退化与数据错乱:如果一个线程执行多个任务(线程池)时,如果每个任务需要的特有对象不同,需要每次在任务开始时重新关联一个线程特有对象,这样就退化成任务特有对象,如果不重新关联,可能导致数据错乱,即多个任务使用同一个对象
ThreadLocal可能导致内存泄漏、伪内存泄漏
使用场景

需要使用非线程安全对象,但又不希望因此而引入锁
使用线程安全对象,但希望避免其使用的锁的开销和相关问题
隐式参数传递:同一线程中的任何方法都能访问到线程特有对象
特定于线程的单例(Singleton)模式
6.5 装饰器模式
通过装饰器模式可以将非线程安全对象改造为线程安全对象,其实就是为每个方法都加锁
java.util.Collections.synchronizedX(X可以是Set、List、Map等):使用装饰器模式将指定的非线程安全的集合对象对外暴露为线程安全的对象
同步集合:synchronizedX返回的集合
使用装饰器模式实现线程安全的缺点
通过同步集合的iterator方法返回的Iterator实例进行遍历,不是线程安全的,因此进行遍历时,需要加锁,且加的锁必须和同步集合内部用于保障其自身线程安全所使用的锁保持一致,因此我们必须知道同步集合对象内部的一些细节,这有违于信息封装原则
同步集合使用的锁粒度较粗,多线程使用时会导致锁的高争用,导致上下文切换变大,性能较差
6.6 并发集合
并发集合:JDK1.5后引入的一些线程安全的集合对象,通常作为同步集合的替代品,支持线程安全的遍历、减小了锁的粒度,甚至使用CAS操作代替锁

对照关系

非线程安全对象 并发集合类 共同接口 遍历实现方式
ArrayList CopyOnWriteArrayList List 快照
HashSet CopyOnWriteArraySet Set 快照
LinkedList ConcurrentLinkedQueue Queue 准实时
HashMap ConcurrentHashMap Map 准实时
TreeMap ConcurrentSkipListMap SortedMap 准实时
TreeSet ConcurrentSkipListSet SortedSet 准实时
遍历实现方式

快照
每次修改集合时,底层都复制一个新数组出来,
取的迭代器迭代的是执行iterator那一时刻的集合中对应的数组,这个数组相当于就是该集合的一个快照,不会随着集合修改而再变化
因此Iterator实例不支持remove方法,因为是从快照中删除,没有实际意义
准实时
遍历过程中其他线程对被遍历对象的内部结构的更新(比如删除了一个元素)可能会(也可能不会)被反映出来
例如ConcurrentHashMap,如果想从链表中间删除元素,必须将整个链表替换成新链表,遍历时,如果正赶上remove,那么得到的相当于也是链表的一个快照,如果正赶上put,相当于得到的是老的链表
Iterator实例可以支持remove方法
如果有多个线程需要对同一并发集合进行遍历操作,那么这些线程不适合共享同一个Iterator实例
ConcurrentLinkedQueue与BlockingQueue

ConcurrentLinkedQueue:适用于一个线程更新集合,另一个线程遍历集合的场景
BlockingQueue:适用于多个线程并发更新集合的场景
ConcurrentHashMap

读取操作(get)基本上不会导致锁的使用

使用分段锁,将桶分成几个段,每个段使用一把锁,因此多个线程向不同段中插入时不会产生锁争用

concurrencyLevel参数可以使我们调整ConcurrentHashMap支持的并发更新线程数,越大会导致开销越大,越小越可能导致并发更新时出现锁的争用

默认支持最多16个线程并发更新而不使用锁

7 线程的活性故障
活性故障:由资源稀缺性或者程序自身的问题和缺陷导致线程一直处于非RUNNABLE状态,或者线程虽然处于RUNNABLE状态但是其要执行的任务却一直无法进展的故障现象
7.1 死锁
两个或者更多的线程因相互等待对方而被永远暂停(BLOCKED或者WAITING)
典型场景:线程A在持有锁L1 的情况下申请锁L2 ,而线程B在持有L2 的情况下申请L1
7.1.1 死锁的检测
线程转储中可以自动检测到死锁
7.1.2 死锁产生的条件与规避
死锁的必要条件

资源互斥:每个资源只能被一个线程使用
资源不可抢夺:锁不能被其他线程抢走,只能等线程自己释放
占用并等待资源:占用资源A同时等待资源B
循环等待资源:T1等待T2持有的资源,T2等待T3持有的资源,T3等待T1持有的资源,最终构成一个环
死锁的代码特征

内部锁

public void deadlockProne(){ synchronized(lockA){ //… synchronized(lockB){ //… } }}
1
外部锁

public void deadlockProne(){ lockA.lock(); try{ lockB.lock(); try{ }finally{ lockB.unlock(); } }finally{ lockA.unlock(); }}
1
外部方法调用

//ClassA有两个同步方法methodA和methodB,ClassB也有两个同步方法methodA和methodB//ClassA的mehtodA会调用ClassB的methodA, ClassB的methodB会调用ClassA的methodB//当一个线程执行ClassA.methodA,另一个线程执行ClassB的methodB,就会造成死锁public enum ClassA { ClassAInstance; public synchronized void methodA() { ClassB.ClassBInstance.methodA(); } public synchronized void methodB() { }}public enum ClassB { ClassBInstance; public synchronized void methodA() { } public synchronized void methodB() { ClassA.ClassAInstance.methodB(); }}
1
通过消除死锁的必要条件解决死锁问题,其中前两个条件是锁自身的性质,无法取消,因此只能消除后两个必要条件

占用并等待资源
粗锁法:线程只需申请一个锁,就不会产生占用一个的同时申请另一个
使用tryLock申请锁:等待锁超过一定时间后就解除等待
开放调用:一个方法在调用外部方法时不持有任何锁
循环等待资源
锁排序法:将所有锁排序,所有线程必须先申请较小的那个锁,申请到较小的锁后,其临界区内才能申请较大的锁,这样就可以避免产生循环
最好可以使用锁的替代品,从根本上避免死锁

7.1.3 死锁的恢复
内部锁或Lock.lock()产生的死锁不可恢复
Lock.lockInterruptibly()产生的死锁可以恢复
启动一个检测线程
通过ThreadMXBean.findDeadlockedThreads()返回一组死锁线程的ThreadInfo
随机选取一个ThreadInfo,通过线程Id,到找Thread.getAllStackTraces().keySet()集合中找到对应的Thread对象
调用该对象的interrupt方法中断该线程
恢复的可操作性不强,通常不用
可能恢复失败:使用Lock.lock产生的死锁
恢复后很快再次死锁
造成活锁问题:例如被中断后,处理InterruptedException异常代码中未释放外层的锁,退出代码后,该线程再次执行原流程,外层锁由于自身未释放还能获取到,而内层锁仍然获取不到,反复如此,造成活锁
7.2 锁死
等待线程永远无法被唤醒

信号丢失锁死

嵌套监视器锁死:monitorY.wait只会释放monitorY,外层的monitorX不会被释放,因此通知线程拿不到外层的锁,永远无法进行通知。ArrayBlockingQueue的put和take方法内部实际上是使用Condition.await和Condition.signal实现的,如果将他们放到同一个锁保护的临界区中使用,就会出现嵌套监视器锁死

//受保护方法synchronized(monitorX){ synchronized(monitorY){ while(!somethingOK){ monitorY.wait(); } }}//通知方法synchronized(monitorX){ synchronized(monitorY){ somethingOK = true; monitorY.notifyAll(); }}
1
7.3 线程饥饿
线程一直无法获得其所需的资源而导致其任务一直无法进展
例如非公平模式的读写锁,如果读取配置数据过于频繁,可能导致修改配置数据的写线程永远无法获取到写锁,最终造成写线程的线程饥饿
死锁、活锁都算线程饥饿,但线程饥饿不一定是死锁或活锁
7.4 活锁
活锁:指线程一直处于运行状态,但是其任务却一直无法进展
例如线程A在持有锁L1 的情况下申请锁L2 ,而线程B在持有L2 的情况下申请L1,当各自申请超时后,不释放外层锁,导致再次尝试申请再次失败
8 线程管理
8.1 线程组
多数情况下,我们可以忽略线程组这一概念以及线程组的存在

8.2 可靠性
当线程的run方法抛出未被捕获的异常,线程会提前终止
如果线程关联了一个UncaughtExceptionHandler对象,那么JVM会将抛出的未被捕获异常交给该对象的uncaughtException方法进行处理,处理完毕后线程才终止
选取UncaughtExceptionHandler对象优先级
thread.setUncaughtExceptionHandler(eh)传入的对象
父线程组:线程组本身就是一个UncaughtExceptionHandler对象
Thread.setDefaultUncaughtExceptionHandler传入的对象
8.3 线程工厂
ThreadFactory是一个线程工厂,可以通过其实现类对象的newThread(Runnable r)方法创建线程实例
我们通常可以自己实现ThreadFactory接口,实现newThread方法,为创建出的线程进行一些合理的设置,例如
为线程关联UncaughtExceptionHandler
设置线程名便于定位问题
确保是用户线程
确保优先级正常
线程创建时打印相关信息
重写线程的toString()方法,便于定位问题
8.4 线程的暂挂与恢复
Thread.suspend()、Thread.resume()都已废弃

8.5 线程池
线程的开销
创建、启动、销毁需要额外开销
线程占用栈空间
线程调度会产生上下文切换,从而增加CPU消耗
线程池可以被看作基于生产者—消费者模式的一种服务
生产者:线程池客户端
产品:任务
消费者:线程池内部维护多个工作者线程从传输通道取任务,调用其run方法
传输通道:线程池内部用于缓存任务的队列
ThreadPoolExecutor:Java对线程池的实现
Future<? > submit(Runnable task):提交任务
ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,BlockingQueue workQueue,ThreadFactory threadFactory,RejectedExecutionHandler handler)
corePoolSize:核心线程池大小
maximumPoolSize:最大线程池大小
keepAliveTime+unit:空闲线程的最大存活时间
workQueue:传输通道
threadFactory:指定创建线程的线程工厂,如果未指定默认使用Executors.defaultThreadFactory ()所返回的默认线程工厂
handler:拒绝处理器,使用rejectedExecution方法对被拒绝的任务进行处理
ThreadPoolExecutor.AbortPolicy:直接抛出异常
ThreadPoolExecutor.DiscardPolicy:不进行任何处理,也就是被拒绝的任务直接消失
ThreadPoolExecutor.DiscardOldestPolicy:将工作队列中最老的任务丢弃,然后重新尝试接纳被拒绝的任务
ThreadPoolExecutor.CallerRunsPolicy:在客户端线程中执行被拒绝的任务
ThreadPoolExecutor.prestartAllCoreThreads:预先创建并启动所有核心线程
ThreadPoolExecutor.shutdown:已提交的任务会被继续执行,新提交的任务会被拒绝掉,如果不执行shutdown,由于线程池中用户线程不会停止,JVM也无法正常退出,内部会发送中断并修改状态
ThreadPoolExecutor.awaitTermination:一直阻塞到所有线程停止
ThreadPoolExecutor.shutdownNow:已提交而等待执行的任务也不会被执行,正在执行的任务也停止,返回值为已提交而等待执行的任务列表,在关闭线程池的时候如果我们能够确保已经提交的任务都已执行完毕并且没有新的任务会被提交,那么调用ThreadPoolExecutor.shutdownNow()总是安全可靠的,内部会发送中断并修改状态
线程池中添加任务流程
初始状态下,客户端每提交一个任务线程池就创建一个工作者线程来处理该任务
在当前线程池大小达到核心线程池大小的时候,新来的任务会被存入工作队列之中
缓存的任务由线程池中的所有工作者线程负责取出进行执行
线程池将任务存入工作队列的时候调用的是BlockingQueue的非阻塞方法offer(E e),因此工作队列满并不会使提交任务的客户端线程暂停
当工作队列满的时候,线程池会继续创建新的工作者线程,直到当前线程池大小达到最大线程池大小
工作者队列满并且当前线程池大小达到最大线程池大小客户端试图提交的任务会被拒绝,并交给拒绝处理器进行处理
超过corePoolSize部分的工作者线程空闲(即工作者队列中没有待处理的任务)时间达到keepAliveTime所指定的时间后线程会自动终止,这样有利于节约线程资源,但如果keepAliveTime值设置不合理可能导致工作者线程频繁地被清理和创建反而增加了开销
8.5.1 任务的处理结果、异常处理与取消
Callable:也是对任务的抽象,任务处理逻辑在call方法中,call方法有返回值,可以抛出异常
Future:对任务的处理结果的抽象
get
返回任务处理结果
执行get时如果任务未结束,会阻塞,因此会导致上下文切换,因此应尽量需要相应任务的处理结果数据的那一刻才调用
如果任务执行过程中产生originalException,那么get方法会抛出对应的ExecutionException,调用ExecutionException的getCause可以得到originalException,因此客户端可以通过捕获get抛出的异常得到任务执行过程中的异常
get(long timeout, TimeUnit unit):等待指定时间,超时后如果任务还未结束抛出TimeoutException
cancel
取消任务,可能取消失败,比如任务已经结束
参数表示是否可以通过发送中断的方式取消任务
返回值表示是否成功取消任务
isCancelled
任务是否被取消,因为任务如果被取消get方法会抛出CancellationException,因此在get方法前通常通过isCancelled判断任务是否被取消
isDone
判断任务是否执行完毕,执行完毕、抛出异常、被取消,该值都为true
8.5.2 线程池监控
ThreadPoolExecutor提供的监控方法
getPoolSize:获取工作线程数
getQueue:返回工作队列
getLargestPoolSize:获取工作线程曾经达到的最大数
getActiveCount:获取正在执行的工作线程数
getTaskCount:获取接收到的总任务数
getCompletedTaskCount:获取处理完毕任务数
ThreadPoolExecutor提供的钩子方法,在任务执行前后调用,可以利用其统计各任务执行时间
beforeExecute
afterExecute
8.5.3 线程池死锁
线程池中执行的任务会提交新任务给线程池,而其结束要依赖于新任务的结束,当线程池中所有线程都在等待新任务的结束,而线程池中又没有线程来执行新任务,就会形成死锁
一个线程池中执行的任务应没有关联,彼此存在依赖关系的任务应该使用不同线程池实例执行
8.5.4 工作者线程的异常终止
submit方法提交给线程池的任务
执行过程中如果出现异常,不会导致其工作者线程中止
我们可以通过Future.get()所抛出的ExecutionException的getCause获取任务中抛出未捕获异常
execute方法提交给线程池的任务
执行过程中如果出现异常,会导致其工作者线程中止
ThreadPoolExecutor可以监测到这种情况,创建并启动新的替代工作者线程,但这耗费系统性能
我们可以通过ThreadPoolExecutor的构造参数或setThreadFactory方法为线程池关联一个线程工厂,线程工厂里面我们可以为其创建的工作者线程关联一个UncaughtExceptionHandler,这样未捕获异常会被该处理器处理,从而不会导致工作者线程被中止
UncaughtExceptionHandler对submit提交的任务不生效
9 Java异步编程
9.1 同步计算与异步计算
同步与异步
同步:任务的发起与任务的执行在同一条时间线上进行
同步任务的发起线程在其发起该任务之后必须等待该任务执行结束才能够执行其他操作
但可以不断轮询查看任务是否执行完毕,轮询不算"其他操作"
异步:不在同一条时间线
通常需要使用多个线程实现
阻塞与非阻塞
阻塞:等待任务结束期间,任务发起线程状态为非Runnable
非阻塞:等待任务结束期间,任务发起线程为Runnable,即正轮询查看任务是否完成
举例
同步阻塞
任务的发起:InpuStream.read
任务:将数据复制到用户空间
现象:这期间read一直阻塞,线程状态为非Runnable,因此用户空间存在数据前无法执行其他操作
同步非阻塞
任务的发起:SocketChannel.read
任务:将数据复制到用户空间
现象:如果内核空间无数据,会直接返回,如果内核空间有数据,会阻塞到将数据复制到用户空间,期间线程状态为非Runnable,用户空间存在数据前也无法执行其他操作
异步阻塞
任务的发起:submit
任务:执行提交的任务
现象:调用Future.get等待线程池中任务结束,期间线程状态为非Runnable
异步非阻塞
任务的发起:submit
任务:执行提交的任务
9.2 Executor框架
Executor:对任务执行者的抽象,提供了一个execute方法来执行任务
优点:客户端使用Executor可以方便地将同步任务改为异步任务,只要替换具体实现即可
缺点
无法将任务处理结果返回给客户端
无法关闭实现类内部的工作者线程
只能接收Runnable任务
ExecutorService:实现Executor,解决了上述问题
submit
可以接受Callable任务
可以将任务处理结果返回客户端
shutdown、shutdownNow
关闭实现类内部的工作者线程
invokeAll
将集合中任务批量提交,该方法会阻塞到所有任务结束,返回所有任务结束后对应的Future集合
9.2.1 Executors工具类
Executors.defaultThreadFactory:返回默认线程工厂
Executors.callable:将Runnable转为Callable,该Callable的call方法中实际上调用的是Runnable的run方法,且只能返回null
创建ExecutorService实例,可以使我们不必自己new ThreadPoolExecutor
newCachedThreadPool:其内使用SynchronousQueue作为传输通道,因此有几个任务会并行执行就创建几个工作着线程
newFixedThreadPool:创建固定个数线程池,因为核心线程不会关闭,因此我们必须在不再需要该线程池时主动将其关闭
newSingleThreadExecutor:单线程的线程池
9.2.2 CompletionService
CompletionService
submit:提交任务
take
阻塞获取结束的任务对应的Future,没有新的结束任务就会阻塞
通常批量提交了多少个异步任务,则多少次连续调用take便可以获取这些任务的处理结果
poll:非阻塞获取Future,如果没有任务结束,返回null
ExecutorCompletionService:CompletionService的实现类,相当于Executor+BlockingQueue
ExecutorCompletionService(Executor executor):相当于使用LinkedBlockingQueue
ExecutorCompletionService(Executor executor,BlockingQueue completionQueue)
9.3 FutureTask
9.3.1 FutureTask简介
其实来说,Callable并不是任务的抽象,FutureTask才是,它代表一个有返回值的任务
FutureTask
同时实现了Future和Runnable
其内部存放了一个Callable实例
通过FutureTask构造器,就可以将Callable对象转为Runnable对象,最终交给Thread(Runnable)或Executor.execute(Runnable)来执行
run:会调用Callable.call
get:会阻塞得到Callable.call的返回值
done:任务结束后的回调方法,其内可以通过get方法获取任务结果,由于此时任务已经结束,get方法不会阻塞,但要注意get方法可能会由于任务执行过程中抛出未捕获异常而抛出CancellationException
AbstractExecutorService.submit可以接收Callable任务,内部其实就是将这个Callable实例转换成一个FutureTask实例,最终交给Executor.execute(Runnable)进行执行
9.3.2 自定义可重复执行的异步任务
FutureTask是只能被执行一次的任务
其run方法中会判断自身状态,未被执行过才能执行,如果被执行过,什么也不做
这很好理解,因为如果能执行多次,那么其get方法返回的到底是哪次的结果呢
FutureTask.runAndReset方法可以多次执行,但任务的返回结果不会被记录,即get无法获取到其返回结果
因此同一个FutureTask实例不能被多次交给Executor执行
我们可以自己实现可重复执行的异步任务类:AsyncTask
实现Runnable和Callable接口
call方法中实现业务逻辑
run方法中调用call方法,并将得到的返回值使用Executor以同步或异步(结果的处理和业务逻辑处理并行)的方式存入数据库或某个队列中
9.4 计划任务
计划任务:需要在指定的时间或者周期性地被执行的任务
清理系统垃圾数据
系统监控
数据备份
ScheduledExecutorService:ExecutorService的子接口,用于执行计划任务
延迟执行任务
ScheduledFuture schedule(Callable/Runnable target, long delay, TimeUnit unit)
delay+unit:延迟多久后开始执行任务
因为任务只执行一次,因此返回值ScheduledFuture可以用来获取任务结果、任务执行中抛出的异常、取消任务
周期性执行任务
执行周期(Interval):两次执行的开始时间之间的时间差
耗时(Execution Time):任务从开始到结束的时间差
ScheduledFuture scheduleAtFixedRate(Runnable command, long initialDelay,long period, TimeUnit unit)
尽量按period作为周期执行任务
initialDelay+unit:任务首次执行延迟
Interval = max(Execution Time, period)
ScheduledFuture scheduleWithFixedDelay(Runnable command, long initialDelay,long delay, TimeUnit unit)
上一次任务执行结束后delay时间开始执行下一次任务
initialDelay+unit:任务首次执行延迟
Interval = Execution Time + delay
以上两个方法都只能传入Runnable,应该也是因为多次执行时,结果没法被保留,因此传Callable对象没意义
无论耗时与period或delay关系如何,都不会在同一时间并发执行任务
由于任务被多次执行,因此返回值ScheduledFuture只能用来取消任务
同样可以考虑提交可重复执行的异步任务AsyncTask给ScheduledExecutorService执行,以记录每次任务执行结果
ScheduledThreadPoolExecutor:ScheduledExecutorService的默认实现类
Executors中创建ScheduledThreadPoolExecutor实例
newScheduledThreadPool
提交给ScheduledExecutorService执行的计划任务在其执行过程中如果抛出未捕获的异常(Uncaught Exception),那么该任务后续就不会再被执行,即使在创建ScheduledExecutorService实例的时指定一个线程工厂,并使线程工厂为其创建的线程关联一个UncaughtExceptionHandler
10 Java多线程程序的调试与测试
11 多线程编程的硬件基础与Java内存模型
11.1 高速缓存
高速缓存内部结构

缓存条目内部结构
Tag:一个桶中可能有多个缓存条目,Tag存放内存地址的部分信息,相当于缓存条目的相对编号
Data Block:也称为缓存行,存放变量值,多个变量可能在同一个缓存行中
Flag:表示该缓存行的状态

内存访问

将内存地址进行解码,得到index、tag、offset
通过index找到对应的桶,通过tag找到对应桶中的缓存条目,最后通过offset确定该变量在该缓存行的起始位置
如果找到的缓存条目的Flag表示这个缓存条目有效,就称相应的内存操作产生了缓存命中
当产生读未命中,处理器所需读取的数据会从主内存中加载并被存入相应的缓存行之中。这个过程会导致处理器停顿(Stall)而不能执行其他指令

查看缓存未命中情况

perf stat -e cache-references, cache-misses java io.github.viscent.mtia.ch1.WelcomeApp
1
11.2 缓存一致性协议
MESI是一种常用的缓存一致性协议
MESI定义的缓存条目的四种状态(Flag)
Invalid:表示缓存行中数据无效
Shared:其他处理器中也存在相同(Tag值相同)的缓存条目,缓存行中数据和主内存一致
Exclusive:其他处理器中不存在相同的缓存条目,缓存行中数据和主内存一致
Modified:该缓存条目中数据为更新后的数据,其他处理器中不存在相同缓存条目,缓存行中数据和主内存不一致
MESI协议定义了一组消息(Message)用于协调各个处理器的读、写内存操作
Read:请求,表示要读取其他处理器的高速缓存中的缓存行
Read Response:响应,返回自身高速缓存中对应的缓存行
Invalidate:请求,表示使其他处理器高速缓存中内容失效
Invalidate Acknowledge:响应表示已让自身高速缓存中缓存行失效
Read Invalidate:相当于Read+Invalidate,必须回复Read Response+Invalidate Acknowledge
Writeback:请求,表示将缓存行写回到主内存
Processor 0读内存地址A上的数据S
根据地址A找到对应的缓存条目
Flag不为I
直接从缓存行中获取数据S
Flag为I:读未命中
发送Read消息到总线
Processor 1探测到该消息
根据地址A找到对应的缓存条目
不为I
为M:将缓存行写入主内存
发送Read Response消息到总线
将缓存行Flag改为S
为I
不发送消息,由主内存发送Read Response消息
Processor 0向内存地址A上写数据
根据地址A找到对应的缓存条目
Flag为E或M
无特殊处理
Flag为S
发送Invalidate消息到总线
Processor 1探测到该消息
根据地址A找到对应的缓存条目
将该缓存条目Flag更新为I
发送Invalidate Acknowledge消息到总线
将缓存行Flag改为E
Flag为I:称为写未命中
发送Read Invalidate消息到总线
Processor 1探测到该消息
根据地址A找到对应的缓存条目
将该缓存条目Flag更新为I
发送Invalidate Acknowledge消息以及Read Response消息到总线
将缓存行Flag改为E
在接收到Invalidate Acknowledge以及Read Response消息后
将数据写入缓存行
将缓存行Flag改为M
Invalidate消息和Invalidate Acknowledge消息使得针对同一个内存地址的写操作在任意一个时刻只能由一个处理器执行,避免了多个处理器同时更新同一数据可能导致的数据不一致问题
11.3 写缓冲器与无效化队列
MESI协议缺点:当Processor 0需要写的数据所在缓存行状态不是E或M时,Processor 0的写操作包含如下动作,这些动作都执行完成,写操作才算完成,这期间cpu不能执行其他指令

发送Invalidate或Read Invalidate消息到总线
其他处理器将自身高速缓存中对应缓存行的Flag设为I
回复Invalidate Acknowledge/Read Response消息
Processor 0收到回复的消息
将数据写入缓存行
为了解决写操作延迟,引入写缓冲器与无效化队列,一个cpu无法读到另一个cpu上写缓冲器中的数据

写缓冲器(Store Buffer,也被称为Write Buffer)

如果缓存行状态不是E或M,将数据行写入写缓冲器,整个写操作就算完成
当收到所有其他处理器回复Invalidate Acknowledge/Read Response消息后,将写缓冲器中内容更新到高速缓存,最后将缓存行Flag改为M
处理器优先从写缓冲器读取数据(存储转发),这是为了保证刚写的内容,在还未收到Invalidate Acknowledge前,还能被读到
注意,由于是后修改Flag为M,因此无论缓存行状态是什么,都会优先从写缓冲器中读取数据
无效化队列(Invalidate Queue)

当收到Invalidate或Read Invalidate消息
将对应缓存行写入无效化队列,就可以直接回复Invalidate Acknowledge
将无效化队列内容更新到高速缓存后,将缓存行状态改为I
11.3.1 再探存储子系统重排序
存储子系统重排序,全是因为写缓冲器和无效化队列导致可见性问题所引发的,因此需要内存屏障来禁止存储子系统重排序

基本内存屏障

每种CPU默认支持的存储子系统重排序不同,处理器支持哪种重排序,就会提供禁止相应重排序的指令,这些指令称为基本内存屏障
CPU一旦发现内存屏障,执行指令时,就不会对其两侧的相关指令进行重排
内存屏障会触发相关的冲刷处理器、刷新处理器动作,保证其两侧指令不会产生存储子系统重排序的现象
写操作如果写的共享变量被写到了写缓冲器,而不是直接写入了高速缓存,就会被另一个线程感觉该写操作被排到了后面

读操作如果读的共享变量在无效化队列,就会被另一个线程感觉读操作被排到了前面

造成内存重排序的具体场景

StoreLoad内存重排序原因:Load提前或Store延后

想造成这种重排序,一个处理器应该先写X再读Y,而另一个处理器应该先写Y再读X

Processor 1如何会感受到重排序

如果L4打印0,那么说明S1未执行,正常来说L2就不应该被执行,如果此时发现L2被执行了,就会感觉到重排序
如果L4打印1,那么说明S1已执行,正常来说如果L2在这之后执行,就应该打印3,如果L2之后打印的不是3就会感觉到重排序了
产生原因

先执行的Store(S1)写入了写缓冲器,导致另一个线程中的L4读到了旧值
后执行的Load(L2)还在无效化队列中,导致其读取到了旧的值
另一个线程中先执行的Store(s3)写入了写缓冲器,导致L2读取到了旧的值
另一个线程中后执行的Load(L4)读取的内容还在无效化队列,导致其取到了旧值
我们无法在当前线程中解决由另一个线程中(后两个原因)引发的重排序

因此StoreLoad屏障为避免重排序的产生,应该额外提供如下功能

清空写缓冲器
清空无效化队列
//XY为共享变量Processor 0 Processor 1 X=1; //S1 Y=3; //S3 sout(Y); //L2 sout(X); //L4
1
StoreStore内存重排序原因:第一个Store延后

想造成这种重排序,一个处理器应该先写X再写Y,而另一个处理器应该先读Y再读X

Processor 1如何会感受到重排序

如果L3打印2,那么说明S1和S2都已执行,那么L4就应该打印1,如果L4此时打印的不是1,就会感觉到重排序
如果L4打印0,那么说明S1和S2都未执行,那么L3就应该打印0,如果L3打印的不是0,就会感觉到重排序
产生原因

先执行的Store(S1)写入了写缓冲器,而后执行的Store(S2)写入了高速缓存,导致先写的后被读到
另一个线程中,后执行的Load(L4)读取的内容在无效化队列中,导致先写的后被读到
我们无法在当前线程中解决由另一个线程中引发的重排序

因此StoreStore屏障为避免重排序的产生,应该额外提供如下功能

清空写缓冲器,或标记写缓冲器中条目,当后续执行写操作时,即使缓存行状态为E或M,也不能直接写入高速缓存,而是应写入写缓冲器
//XY为共享变量Processor 0 Processor 1X=1; //S1 sout(Y); //L3 Y=2; //S2 sout(X); //L4
1
LoadLoad内存重排序原因:第二个Load提前

其实场景和StoreStore内存重排序一样,当从Processor 1角度看Processor 0,觉得其发生StoreStore重排序时,从Processor 0的角度看Processor 1就是LoadLoad重排序
我们无法在当前线程中解决由另一个线程中引发的重排序
因此LoadLoad屏障为避免重排序的产生,应该额外提供如下功能
清空无效化队列
LoadStore内存重排序原因

理论上来说Store只会延后,Load只能提前,所以不知道什么场景会产生LoadStore内存重排序
但LoadStore屏障至少可以禁止cpu产生的重排序
其实我们可以发现在某个线程中单独使用某个内存屏障,是无法根本解决内存重排序的,因此为了解决内存重排序,需要在读线程和写线程中配套使用相应的屏障,才能达到彻底禁止内存重排序的效果

写合并可能造成StoreStore重排序

写合并指处理器(例如ARM处理器)为了充分利用总线带宽,以提高将写缓冲器中的内容冲刷到高速缓存的效率,将针对连续内存地址的不同时间的写操作并入同一个写缓冲器条目之中
那么后写入的操作就可能先被冲刷进高速缓存,从而造成StoreStore重排序
可能对于ARM处理器提供的StoreStore屏障就会进一步提供禁止写合并的功能
11.3.2 再探可见性
产生可见性问题的几个原因

读线程,如果发现数据所在缓存行如果为I,才会从其他处理器的高速缓冲中获取数据,而将缓存行改为I的操作在无效化队列更新到高速缓存之后,因此如果无效化队列中存在数据,就不会从其他处理器获取高速缓存中数据,从而导致可见性问题

即使读线程清空了无效化队列,想从其他处理器的高速缓存中同步数据,如果此时其他处理器写入的数据还在写缓冲器中,那么还是无法获取最新数据,从而导致可见性问题

存储转发的副作用:由于存储转发,处理器会优先从写缓冲器读取数据,如果读线程的写缓冲器中有数据,就不会从其他处理器的高速缓存中读取数据,从而读到一个过期的值。注意存储转发的副作用只会在同一个线程中先修改再读取时才存在,如果一个线程只读取数据,并不会引起存储转发的副作用

储转会导致LoadLoad重排序和StoreLoad重排序,当L3打印2,L4打印5,在Processor 0看来,可能是S5和L3重排序,也可能是L3和L4重排序,为了避免这种情况产生,只能在S5和L3之间插入StoreLoad屏障,此时已经清理了写缓冲器和无效化队列,就不会再发生L3和L4的StoreStore重排序了,因此其实存储转发的问题需要由StoreLoad屏障解决,因此LoadLoad屏障未额外提供清空写缓冲器的功能

如果在L3和L4之间插入LoadLoad屏障,并为LoadLoad屏障提供清空写缓冲器的功能,还是没法避免存储转发带来的可见性问题,因为此时S5还是能排到L4前,内存屏障后,然后由于存储转发还是让Processor 0感觉到发生了重排序

因此LoadLoad屏障不提供清空写缓冲器功能

//XY为共享变量Processor 0 Processor 1 X=5; //S5X=1; //S1 sout(Y); //L3 Y=2; //S2 sout(X); //L4
1
因此为了保证可见性

写线程的执行处理器应在该线程对共享变量所做的更新后清理写缓冲器,保证该共享变量对读线程来说是可同步的
读线程的执行处理器应该在读共享变量前清理无效化队列和写缓冲器,从而将写线程对共享变量所做的更新同步到该处理器的高速缓存之中
11.4 Java同步机制与内存屏障
获取屏障:LoadLoad屏障+LoadStore屏障
释放屏障:LoadStore屏障+StoreStore屏障
存储屏障:StoreStore屏障
加载屏障:LoadLoad屏障
11.4.1 volatile关键字的实现
释放屏障(LoadStore+StoreStore)–> volatile写 --> StoreLoad屏障
LoadLoad屏障 --> volatile读 --> 获取屏障(LoadLoad+LoadStore)
释放屏障+获取屏障
共同保证了volatile写以及其之前的操作,一定发生在其后续的volatile读以及其后的操作之前,保证了volatile的有序性
StoreLoad屏障
充当存储屏障:清空写缓冲器,保证volatile写以及其之前所有写操作的内容可以被其他处理器同步
充当加载屏障:清空无效化队列,为其之后的volatile读消除存储转发的副作用
LoadLoad屏障
充当加载屏障:清空无效化队列,保证后续读操作有机会读取到其他处理器对共享变量所做的更新
由于存储转发的副作用,只会发生在同一个线程中一个共享变量先写后读的场景,一个线程中写,另一个线程中读时,读线程中该共享变量不可能在写缓冲器中,因此一定不会从写缓冲器中读取,也就不会引起存储转发副作用,因此volatile写后的StoreLoad屏障只是为了与其同一线程的之后的volatile读的存储转发副作用,而不是为了消除另一个线程中的volatile读的存储转发副作用,因此完全可以在volatile写后加StoreStore屏障,而在volatile读前加StoreLoad屏障,同样能达到相同的效果,但由于StoreLoad屏障比较重,考虑到通常情况下,volatile写的场景要远小于volatile读,所以将这个较重的屏障,加载发生较少的volatile写操作后
由于x86处理器仅支持StoreLoad重排序,因此在x86处理器下Java虚拟机会将LoadLoad屏障、LoadStore屏障以及StoreStore屏障映射为空指令,只需要在volatile写操作后插入一个StoreLoad屏障
11.4.2 synchronized关键字的实现
monitorenter(Load) --> LoadLoad屏障 --> 获取屏障(LoadLoad+LoadStore)–> 临界区 --> 释放屏障(LoadStore+StoreStore) --> monitorexit --> StoreLoad屏障
释放屏障+获取屏障
保证临界区中代码不会被重排序到临界区外,加上锁的排他性,使得临界区内操作具备原子性
临界区内、临界区前和临界区后这3个区域内的任意两个操作都可以在各自的区域范围内进行重排序,只要相应的重排序能够满足貌似串行语义
临界区外代码可以被重排到临界区内
LoadLoad屏障
与volatile相同
StoreLoad屏障
与volatile相同
保证monitorexit不会与后续的monitorenter重排序,从而使synchronized块的并列以及synchronized块的嵌套成为可能
11.4.3 Java虚拟机对内存屏障使用的优化
两个连续的volatile写,Java虚拟机可能只在最后一个volatile写后插入StoreLoad屏障
Java虚拟机对monitorexit的实现本身就带有StoreLoad屏障的效果,因此不会在其后再插入StoreLoad屏障
11.4.4 final关键字的实现
JIT会保证final变量的初始化操作不会重排序到其所在类的构造器之后,但不保证其前非final变量的初始化被重排序到构造器之后
final变量初始化 --> StoreStore屏障
11.5 Java内存模型
Java内存模型 (Java Memory Model, JMM)是Java语言规范(The Java Language Specification, JLS)的一部分,用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果
JMM规定了Java语言对可见性、有序性应该有哪些保障,但不具体规定如何实现这些保障
11.5.1 happen(s)-before
JMM中定义了一些动作,happen(s)-before是两个动作之间的关系,如A happen(s)-before B,简写为A→B
JMM要求,如果A→B,那么A的结果必须对B可见
应用层面是通过Java线程同步机制实现的
底层由Java虚拟机、编译器以及处理器一同协作来落实的
以实现unlock之前的动作结果对后续执行lock的线程可见为例
为了保证这点,Java虚拟机、编译器(JIT)必须保证编译后的机器码中,unlock前代码不能指令重排序到unlock后
而为了保证处理器执行机器码时,也能实现这点,JIT编译器会在monitorexit对应的机器码指令前插入一个释放屏障
happens-before关系具有传递性,A→B,B→C,那么A→C
如果一组动作中的每个动作与另外一组动作中的任意一个动作都具有happens-before关系,那么我们可以称前一组动作与后一组动作之间存在happen-before关系,注意happen不是单数了
JMM中定义了一些规则,规定在哪种情况下,两个动作间存在happen(s)-before关系
程序顺序规则:如果两个动作在一个线程中,那么前面的动作happens-before后面的动作
其实就是说,如果两个动作在同一个线程中时,后面代码一定能看到前面代码的执行结果
其实就是貌似串行语义
A happens-before B不是说A就一定先执行,而是说,A的结果一定对B可见,也就是说,如果A、B毫不相关,那么即使B先发生,也可以说A happens-before B,因为A的结果对B可见,但其实所谓的可见是因为B根本没看A
内部锁规则:内部锁的释放(unlock)happens-before后续(Subsequent)每一个对该锁的申请(lock)
“释放”和“申请”必须是针对同一锁实例,否则它们间无happens-before关系
“后续”是指时间上的先后关系
由于happens-before关系的传递性+程序顺序规则,就保证了临界区中能看到上一个执行的临界区中的结果,以及上一个临界区之前的代码的结果,虽然能看见但不保证是最新值
volatile变量规则:对一个volatile变量的写操作happens-before后续每一个针对该变量的读操作
同一个volatile变量的写、读操作之间才有happens-before关系
后续是指时间上的先后关系
线程启动规则:调用一个线程的start方法happens-before被启动的这个线程中的任何一个动作
就是说,T1中调用了T2.start,那么T1中执行T2.start前的所有动作,对T2中的动作可见
线程终止规则:一个线程中的任何一个动作都happens-before该线程的join方法的执行线程在join方法返回之后所执行的任意一个动作
就是说,T1中调用了T2.join,那么T2中任何动作,对T1中T2.join之后的动作可见
Java标准库类定义的happen(s)-before规则
一个线程在countDownLatch.countDown()调用前所执行的所有动作与另外一个线程在countDownLatch.await()调用成功返回之后所执行的所有动作之间存在happen-before关系
一个线程在blockingQueue.put(E)调用所执行的所有动作与另外一个线程在blockingQueue.take()调用返回之后所执行的所有动作之间存在happen-before关系
cyclicBarrier.await()前所执行的任何操作与barrierAction.run()中的任何操作存在happen-before关系,barrierAction.run()中所执行的任何操作与所有参与方在cyclicBarrier.await()调用成功返回之后的代码存在happen-before关系
11.6 共享变量与性能
多个线程修改同一个共享变量,会造成缓存未命中,不利于程序性能,应避免多个线程频繁写同一个共享变量
12 Java多线程程序的性能调校
12.1 Java虚拟机对内部锁的优化
自Java 6/Java 7开始,Java虚拟机对内部锁的实现进行了一些优化,包括锁消除(Lock Elision)、锁粗化(Lock Coarsening)、偏向锁(Biased Locking)以及适应性锁(Adaptive Locking)。这些优化仅在Java虚拟机server模式下起作用

12.1.1 锁消除
锁消除类似如下功能

//锁消除前synchronized(monitor){ doSomething(); }//锁消除后doSomething();
1
逃逸分析:判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程

JIT借助逃逸分析,如果发现同步块使用的锁只被一个线程访问,那么JIT编译这个同步块时,其前后不生成monitorenter(申请锁)和monitorexit(释放锁)这两个字节码指令

内联:B方法中调用A方法时,JIT编译后的指令中,可能会将A中的指令复制到B中一份

例如A方法中使用局部变量StringBuffer的append/toString方法

JIT会先将toString/append内联到方法A中
之后经过逃逸分析发现append/toString使用的锁StringBuffer对象为局部变量
最后JIT将A方法中从StringBuffer.append/toString方法的方法体复制的指令所使用的内部锁消除
注意StringBuffer.append/toString方法本身所使用的锁并不会被消除
12.1.2 锁粗化
锁粗化类似如下功能

//锁粗化前synchronized(monitorX){ doSomething1();}synchronized(monitorX){ doSomething2();}synchronized(monitorX){ doSomething3();}//锁粗化后synchronized(monitorX){ doSomething1(); doSomething2(); doSomething3();}
1
对于相邻的几个同步块,如果这些同步块使用的是同一个锁实例,那么JIT编译器会将这些同步块合并为一个大同步块,从而避免了一个线程反复申请、释放同一个锁所导致的开销

锁粗化会导致一个线程持续持有一个锁的时间变长,从而使得同步在该锁之上的其他线程在申请锁时的等待时间变长

锁粗化不会被应用到循环体内的相邻同步块

12.1.3 偏向锁
Java虚拟机如果发现大部分锁未被争用,并且这些锁在其整个生命周期内至多只会被一个线程持有,那么会JVM会开启偏向锁的优化
Java虚拟机在实现monitorenter字节码(申请锁)和monitorexit字节码(释放锁)时需要借助一个原子操作(CAS操作),这个操作代价相对来说比较昂贵
开启偏向锁后,JVM会为每个锁维护一个偏好线程,这个线程后续无论是再次申请该锁还是释放该锁,都无须借助原先(指未实施偏向锁优化前)昂贵的原子操作,从而减少了锁的申请与释放的开销
但当偏好线程以外的线程访问该锁,JVM会重新设置该锁的偏好线程,这种操作比CAS更昂贵
因此如果系统中存在大量被争用的锁而没有被争用的锁仅占极小的部分,那么我们可以考虑关闭偏向锁优化
12.1.4 适应性锁
忙等:反复执行空操作直到所需的条件成立

while (lockIsHeldByOtherThread){}
1
当争锁失败时,如果不将线程暂停,而是采用忙等策略,这样就不会进行上下文切换,但会耗费处理器资源

JVM会根据其运行过程中收集到的信息来判断这个锁是属于被线程持有时间“较长”的还是“较短”的,对于被线程持有时间“较长”的锁,Java虚拟机会选用暂停等待策略,对于被线程持有时间“较短”的锁,Java虚拟机会选用忙等等待策略,忙等指定次数或指定时间后如果还不能获取到锁,就将线程暂停,这种优化就被称为适应性锁

12.2 优化对锁的使用
从应用代码角度来优化对锁的使用,对内部锁和显示锁都适用

12.2.1 锁的开销与锁争用监视
锁的开销
上下文切换与线程调度
内存同步、编译器优化受限
并行度受限
减少锁的开销
消除锁的使用
降低锁的争用
降低申请锁的频率:并发最大频率越高
减小锁的粒度
减少锁被持有的时间
减小临界区长度
使用JMC可以监控锁的争用次数、申请锁平均等待时间
12.2.2 使用可参数化锁
可参数化锁:方法或者类内部锁使用的锁实例可以由该方法、类的客户端代码指定
java.io.Writer就使用了可参数化的锁
其write/flush/close等方法所需的内部锁默认使用调用对应方法的Writer实例
使用的锁也可以通过Writer(Object lock)构造器传入
可参数化的锁在特定情况下有助于减少线程执行过程中参与的锁实例的个数,从而减少锁的开销,比如涉及嵌套申请多个锁时,指定使用同一个锁实例,这样由于锁的可重入性,节省了一次申请释放锁的开销
使用可参数化锁一定程度上破坏了封装性,因为必须知道方法中使用哪个锁,假如指定的锁实例被其他代码不恰当地使用了,那么可参数化锁的使用可能会增加锁的争用
12.2.3 减小临界区的长度
临界区操作通常分为几部分
预处理操作
共享变量访问
后处理操作
其中预处理和后处理通常不涉及共享变量访问,因此应尽量将这两个操作移到临界区外以减小临界区的长度,如果他们中涉及I/O、阻塞等比较耗时的操作,那么将他们挪到临界区外可以有效地减少锁被持有的时间
12.2.4 减小锁的粒度
减小锁的粒度可以降低锁的申请频率,从而减小锁被争用的概率
锁拆分技术:将一个粒度较粗的锁拆分成若干粒度更细的锁,其中每个锁仅负责保护(Guard)原粗粒度锁所保护的所有共享变量中的一部分共享变量
原来使用锁lock保护共享变量X、Y、Z
拆分后使用锁lock1保护共享变量X、Y,使用锁lock2保护共享变量Z
锁分段:对同一个数据结构内不同部分的数据使用不同锁实例进行加锁
ConcurrentHashMap内部就使用了锁分段技术
内部会创建N (默认值为16)个锁实例
同时执行put操作的不同线程只要其提供的key值不一样,那么它们所需要使用的锁实例也可能是不一样的
从而使一个锁实例保护多个桶(Bucket)中的条目
锁分段会使对整个对象进行加锁变得困难甚至于不可能
12.2.5 考虑锁的替代品
volatile关键字、原子变量、无状态对象、不可变对象、线程特有对象

12.3 减少上下文切换
上下文切换次数越多,处理器资源消耗越多,真正能够用于执行应用代码的处理器资源越少
减少上下文切换的方法
减少锁的争用、避免锁的使用
控制线程数量:Executors.newCachedThreadPool返回的线程池会造成创建远超过cpu个数的线程,可以借助Semaphore控制并发线程数
避免在临界区中执行阻塞式I/O(Blocking I/O)等阻塞操作:临界区中的阻塞操作会增加引导这个临界区的锁被争用的可能性,而被争用的锁又可能导致上下文切换
可以通过使用单线程来执行I/O操作避免
避免在临界区中执行比较耗时的操作
减少Java虚拟机的垃圾回收:会导致Stop-the-World事件
12.4 伪共享
伪共享:是一种现象,由于一个缓存行中可以存储多个变量的副本,导致即便是在两个线程各自仅访问各自的共享变量的情况下,一个线程更新其共享变量也可能导致另外一个线程访问其共享变量时产生缓存未命中
判断两个共享变量是否可能被加载到同一个缓存行之中,需要了解Java对象的内存布局
12.4.1 Java对象内存布局
Java对象在内存中包括头+N个实例变量

头:64位系统一般占用12字节
存储对象的HashCode、锁(偏向线程ID)
对象所属Class的指针
如果对象为数组,额外多1个字存放数组长度
实例变量
JVM为对象头及实例字段分配内存空间的规则

对象以8字节对齐进行存储
将内存空间看成一个个8字节的小格子组成
假设4个数据a(占用4字节)、b(占用4字节)、c(占用4字节)和d(占用8字节)要存入内存
先将a存入第1个小格子,此时这个小格子还剩4字节,可以用来存放b。然后将c存入第2个小格子,此时这个小格子只剩下4字节,不够存放d,因此会先使用一个4字节的填充材料(Padding)将第二小格填满,最后将d存入第3个小格子
填充会造成空间浪费
为减少浪费,JVM存储实例变量时,是按实例变量占用空间大小的顺序进行存储
long型变量和double型变量
int型变量和float型变量
short型变量和char型变量
boolean型变量和byte型变量
引用型变量
继承自父类的实例字段不会与类本身定义的实例字段混杂在一起进行存储
OpenJDK中提供jol工具,可以查看对象的实际内存布局

java -XX:-RestrictContended -cp ./jol/jol-cli-0.6-full.jar org.openjdk.jol.Main internals java.util.concurrent.ThreadLocalRandom
1
12.4.2 伪共享的侦测与消除
对于CPU密集型任务,在没有锁争用的情况下,使用N(N<=CPU核数)个线程执行N个任务的平均时间,应该和使用单个线程执行单个任务的时间相近,如果差距很大就可能是产生了伪共享问题

使用perf命令可以检测缓存未命中数量,如果缓存未命中数量非常大,可能是因为产生了伪共享问题

perf stat -e cpu-clock, task-clock, cs, instructions, L1-dcache-load-misses, L1-dcache-store-misses, LLC-loads, LLC-stores java io.github.viscent.mtia.ch12.FalseSharingDemo 1
1
案例

DefaultCountingTask

public class DefaultCountingTask{ private long iterations; private volatile long value; //其他代码省略}
1
使用jol工具分析DefaultCountingTask对象占32字节,因此两个DefaultCountingTask可以被加载到一个宽度为64字节的缓存行中

当在程序中连续创建多个DefaultCountingTask实例,连续创建的两个DefaultCountingTask实例极有可能会被Java虚拟机安排在连续的内存空间中进行存储

消除伪共享

JDK1.8以前:手动填充

程序中多个线程访问的共享变量为value,根据jol分析结果,其前面会存放DefaultCountingTask对象头和iterations,共24字节,即两个对象的value间的距离为24字节

为了保证两个对象的value不在同一缓存行中,应该考虑最差情况,第一个对象的value恰好在一个缓存行的开头,此时为了保证第二个对象的value与其不在同一缓存行,应该在第一个value后填充64(缓存行)-24(到下一个value开始距离)-8(value长度)=32个字节的内容

填充后代码

public class DefaultCountingTask implements CountingTask { private final long iterations; public volatile long value; //需要填充32字节,每个long是8字节,因此需要填充4个 //需要使用protected关键字,防止被JVM认为是无用的字段而将其优化掉 protected volatile long p1, p2, p3, p4;}
1
JDK1.8以后:自动填充

手动填充缺点
需要知道系统的缓存行宽度,但不同处理器缓存行宽度不同
需要了解Java对象的内存布局,但不同JVM内存布局不同
由以上两点导致可移植性问题
@sun.misc.Contended:注释属性和类,表示提示JVM注释的属性或类的实例可能存在伪共享问题,JVM会在其前后各填充缓存行宽度的2倍的填充空间,以保证他们彼此不在同一缓存行,因此比手动填充更浪费空间
需要配置-XX:-RestrictContended启动参数才能开启自动填充
————————————————
版权声明:本文为CSDN博主「含低调」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/hanzong110/article/details/118382310

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值