【读书笔记】深入理解Java虚拟机(周志明)(5)第五部分 高效并发
文章说明
本文是《深入理解Java虚拟机(周志明)》这本书的重点摘要。
本笔记仅作为复习,不过多的对内容进行讲解。
本笔记按照书的目录进行,如遇到需要细看的,可以到书中找对应内容。
本笔记并不是按照书中原话进行摘要,而是根据自己的理解使用大白话进行记录,同时进行了少部分扩展。如有错误欢迎指出。
由于内容较多,一共分为三篇:
篇幅 | 链接 |
---|---|
深入理解Java虚拟机(周志明)(1)第一部分 走进Java(2)第二部分 自动内存管理机制 | https://blog.csdn.net/zhaohongfei_358/article/details/134927759 |
深入理解Java虚拟机(周志明)(3)第三部分 虚拟机执行子系统(4)第四部分 程序编译与代码优化 | https://blog.csdn.net/zhaohongfei_358/article/details/135067398 |
深入理解Java虚拟机(周志明)(5)第五部分 高效并发 | https://blog.csdn.net/zhaohongfei_358/article/details/135111650 |
第五部分 高效并发
第12章 Java内存模型与线程
12.1 概述
无重点
12.2 硬件的效率与一致性
硬件在并行计算时处理一致性问题的方式是增加了一层“缓存一致性协议”,如图所示:
12.3 Java内存模型
Java内存模型的主要目标是:定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。
12.3.1 主内存与工作内存
Java内存分为主内存和线程工作内存。
主内存线程共享。
各个线程有自己的工作内存。
线程只能使用自己的工作内存,不能直接读写主内存,只能将主内存的变量拷贝一个副本到自己的工作内存,然后再同步给主内存。
12.3.2 内存间交互操作
Java内存模型定义了8种“原子操作”来进行工作内存和主内存的交互:
- lock:锁定主内存中的某个变量;
- unlock:释放lock锁定的变量锁。
- read:将主内存的变量传输到工作内存中。
- load:将read到工作内存的变量生成副本。
- use:使用工作内存中的变量,即将变量值传递给执行引擎。
- assign(赋值):将执行引擎返回的结果值赋值给工作内存中的变量。
- store:将工作内存的值传输到主内存。
- write:将store过来的变量值放入到主内存变量中。
线程读变量就是“read+load”操作,写变量就是“store+write”操作。
read和load之间可以插入其他操作,例如:可以 read a, read b, load a, load b。 (store+write同理)
并发问题举例:
假设A,B线程都要对变量x进行x++
操作:
- 正常执行顺序:① A线程从主内存读取
x=1
变量;② A线程执行x++
操作;③ A线程将x=2
写回主内存。④ B线程从主内存读取x=2
变量;⑤ B线程执行x++
操作;⑥ B线程将x=3
写回主内存。⑦ 最终主内存中,x=3
- 异常执行顺序:① A线程从主内存读取
x=1
变量;② A线程执行x++
操作;③ B线程从主内存读取x=1
变量;④ A线程将x=2
写回主内存;⑤ B线程执行x++
操作;⑥ B线程将x=2
写回主内存;⑦ 最终主内存中,x=2
产生并发问题,x少加了一次。
12.3.3 对于volatile型变量的特殊规则
volatile
的两个作用:
- volatile是保证此变量对所有线程的可见性。
- 禁止指令重排序优化
上一节说到,每个线程都有自己的工作线程。因此,当变量增加了volatile
修饰符,表示该变量的所有修改对其他线程都是立即可见的,即其他线程知道该变量被其他线程修改了。
注意:但volatile
变量并不是线程安全的。
线程在使用volatile
变量前,每次都要刷新。但是在后面执行引擎“做变量计算”期间,若该变量被其他线程修改,则会产生不一致问题。
不一致问题举例:假设A,B两个线程都要“不断地”对volatile
的x
变量做x=x+1
操作:
- A线程读取
x=0
- B线程读取
x=0
- A线程执行
x=x+1
操作 - 由于
x
是volatile
的,B线程刷新x
的值,此时B线程中x=1
。 - B线程执行
x+1
(即1+1
)操作。(注意:这里B线程只是做了x+1
操作,还没进行赋值) - A线程又一次执行了
x=x+1
操作。此时,x=2
了。 - B线程
1+1
执行完毕,执行x赋值操作,即x=2
。 - 最终,A线程执行了两次,B线程执行了1次,但最终
x=2
,少了一次。
因此,Volatile由于具备了可见性,可以一定程度上缓解不一致问题,但并不能完全避免。
volatile
的使用场景:当某个变量只会被一个线程做修改操作时,volatile
就可以保证一致性。这就决定了它的应用场景。
例如:
// 一个资源类只需要被关闭一次,即只有一个线程会对该变量做修改操作。
volatile boolean shutdownRequested;
public void shutdown() {
shutdownRequested = true;
}
public void doWork() {
while (!shutdownRequested) {
// do something
}
}
12.3.4 对于long和double型变量的特殊规则
对于long和double这些64位的数据类型。Java内存模型规定在做read
和write
(12.3.2中提到的)操作时可以将其分成两个32位进行操作,即可以不保证其原子性。(可能是为了加速吧)这样就可能会读到半个long变量
不过现在的虚拟机在实现时还是将64位的
read
和write
按照原子操作实现,因此不用担心上述的事情。
12.3.5 原子性、可见性与有序性
原子性(Atomictiy):Java内存模型的操作(read
、write
、assign
等,见12.3.2节)都是原子性的。
可见性(Visibility):被volatile
修饰的变量在线程之间是可见的。(见12.3.3节)
有序性(Ordering):如果在本线程来看,所有的操作都是有序的,即“线程内‘表现为’串行的语义”。但如果在一个线程中观察另一个线程,所有的操作都是无序的,即“指令重排序”和“工作内存与主内存同步延迟”现象。
12.3.6 先行发生原则
Java内存模型定义了“先行发生(happens-before)”,我们可以通过这些原则来判断哪些操作一定是先执行的,帮助排查并发问题。
具体有:
- 程序次序规则(Program Order Rule):一个线程内,控制流顺序一定是按照顺序执行的。
- 管程锁定规则(Monitor Lock Rule):
unlock
操作一定比起对应的lock
操作后执行 - volatile变量规则(Volatile Variable Rule):volatile的变量的写操作一定比其后面的读操作先执行
- …
12.4 Java与线程
12.4.1 线程的实现
Java中的Thread
类的许多方法都是native
的,这是因为对线程的实现,不同平台区别较大,无法做到平台无关。
实现线程主要有三种方式:
- 使用内核线程实现:由操作系统内核进行线程管理(创建、切换、调度等)。多个线程可以同时使用多个CPU,当一个线程阻塞,不影响其他线程的继续运行。
- 使用用户线程实现:由用户态直接进行线程管理。操作系统不知道用户线程的存在(可以理解为就是假线程),因此无法利用多CPU并行计算,只能并发运行。若一个线程阻塞,就会影响到其他线程。用户线程的优点是轻量级,创建、销毁等都比较快。
- 混合实现:同时结合了内核线程和用户线程两种模式。
12.4.2 Java线程调度
线程调度是指系统为线程分配CPU使用权的过程。有两种方式:
- 协同式调度:线程自己决定什么时候让出CPU。
- 抢占式调度(Java线程采用的方式):由操作系统自行决定线程的执行和挂起。不过可以通过
thread.setPriority(int newPriority)
来设置线程的优先级。
12.4.3 状态转换
Java线程有5种状态:
- 新建(New):创建后尚未启动。
- 运行(Runnable):线程正在CPU上运行或者等待被CPU调度。
- 无限期等待(Waiting):线程无限等待,直到被其他线程唤醒。下面代码会产生无限等待:
obj.wait()
:当线程A对某个被synchronized(obj)
了的obj
对象调用了obj.wait()
后,则线程A会暂时释放该对象的锁(表示自己需要的资源还没到,先让出锁),并且线程A开始阻塞,直到其他线程调用了obj.notify()
。当另一个线程B在获取了obj
的锁后,即synchronized(obj)
,若调用了obj.notify()
,则线程A就会恢复执行。thread.join()
:如果线程A中调用了threadB.join()
,那么线程A就会阻塞,直到threadB
运行结束。
- 限期等待(Timed Waiting):线程等待,但有个等待时间。若调用了
obj.wait(long timeout)
或thread.join(long millis)
,即给wait
或join
方法传了个超时时间,那就是限期等待。Thread.sleep(long timeout)
也算限期等待。 - 阻塞(Blocked):当线程在等待获取锁时,就处于阻塞状态。
- 结束(Terminated):线程执行结束。
第13章 线程安全与锁优化
13.1 概述
本章是将如何保证线程并发的安全与高效。
13.2 线程安全
线程安全:若一个对象具备“调用者在调用时不需要考虑多线程会带来错误的结果,所有的正确性保障手段(如互斥同步等)都在对象内部被封装好了”,那么我们就说这个对象是线程安全的。
13.2.1 Java语言中的线程安全
我们可以按照将一个对象的线程安全程度将其从高到低划分为5类:
- 不可变(Immutable):如果一个对象是不可变的,那么是一定线程安全的。例如:被final修饰的变量,被
Collections.unmodifiableCollection(...)
修饰的集合等。 - 绝对线程安全:不需要任何同步措施的对象。反例:
Vector
大家都知道是线程安全的,但其并不是绝对线程安全的。假设两个线程同时对一个vector做remove
,即for (int i=0; i<vector.size(); i++) {vector.remove(i)}
,最终会报数组越界错误。此时就要加入一些同步措施保证线程的安全。 - 相对线程安全:通常意义上的线程安全。例如:
Vector
、HashTable
、ConcurrentHashMap
。在必要时候(例如2中的例子),还是需要做一些同步措施。 - 线程兼容:一个对象虽然不是线程安全的,但是我们可以增加一些额外的同步措施来保证线程安全。例如:
ArrayList
等 - 线程对立:无论采用什么同步方式都会产生并发问题。例如:
Thread.suspend
和Thread.resume()
。
13.2.2 线程安全的实现方法
实现线程安全通常有以下几种方案:
- 互斥同步(Mutual Exclusion&synchronization):对需要线程安全的对象进行加锁。可以使用
sychronized
、ReentrantLock
等。 - 非阻塞同步:先进行操作,如果没有其他线程争用共享数据,则操作成功。否则,就采取其他措施(例如重试或报错)。
- 无同步方案:如果一段代码不涉及共享数据,那么这段代码就是线程安全的。
13.3 锁优化
13.3.1 自旋锁与自适应自旋
当一个线程需要等待锁时,会有两种阻塞方式:
- 挂起线程:由操作系统将线程挂起或恢复。这些操作需要在内核态完成。适合阻塞时间较长的线程。
- 自旋:若预计阻塞时间很短,则可以采用自旋方式,即类似
while(true) {}
这样。线程依然会在CPU上运行,没有被挂起。
JDK采取了一套方案来预测阻塞时长,自适应的决定要不要采用自旋方式加锁,即自适应自旋。
13.3.2 锁消除
如果一段代码加了锁,但虚拟机发现其实没有必要加锁,那么在真正执行的时候实际上是不会上锁的。(即11.3.5提到的“逃逸分析”)
13.3.3 锁粗化
在加锁时要把我两个原则:
- 锁要细:同步操作尽可能小(即需要被加锁的代码尽可能少),这样可以让锁快速被释放,不至于其他线程等待太久。
- 锁又要粗:由于加解锁是个比较耗资源的行为,如果太频繁也不好。
因此,实践中1,2最好做一个权衡取舍。例如:循环中的频繁加锁就可以看看能否挪出来放在循环外面。
13.3.4 轻量级锁
在无竞争的情况下使用CAS操作消除同步使用的互斥量,以达到优化性能的目的。
13.3.5 偏向锁
在无竞争的情况下,第一个获取锁的线程实际上不加锁。等到有第二个线程要获取锁时,再恢复第一个线程的锁。