全网最详细并发编程(1)—进阶篇
一、共享模型之内存
之前的入门篇主要讲解的Monitor主要关注的是访问共享变量时,保证临界区代码的原子性,本文我们将进一步深入学习共享变量在多线程间的【可见性】问题与多条指令执行时的【有序性】问题。
1.1 、 Java 内存模型
JMM 即 Java Memory Model,它定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等
1.2、可见性
退不出的循环
先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:
static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(()->{
while(run){
// ....
}
});
t.start();
sleep(1);
run = false; // 线程t不会如预想的停下来
}
为什么呢?分析一下:
- 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。
- 因为 t 线程要频繁从主内存中读取 run 的值,JIT 即时编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率。
- 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值。
解决方法
volatile(易变关键字)
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存。
可见性 vs 原子性
前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见, 不能保证原子性,仅用在一个写线程,多个读线程的情况: 上例从字节码理解是这样的:
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
putstatic run // 线程 main 修改 run 为 false, 仅此一次
getstatic run // 线程 t 获取 run false
比较一下之前我们将线程安全时举的例子:两个线程一个 i++ 一个 i-- ,只能保证看到最新值,不能解决指令交错。
// 假设i的初始值为0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1
注意: synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized 是属于重量级操作,性能相对更低。
如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了,想一想为什么?
答:因为System.out.println()底层加了synchronize锁从而保证了可见性。
模式之两阶段终止(使用Bulking犹豫模式volatile改进)
import lombok.extern.slf4j.Slf4j;
@Slf4j(topic = "c.TwoPhaseTermination")
public class Test13 {
public static void main(String[] args) throws InterruptedException {
TwoPhaseTermination tpt = new TwoPhaseTermination();
tpt.start();
tpt.start();
tpt.start();
/*Thread.sleep(3500);
log.debug("停止监控");
tpt.stop();*/
}
}
@Slf4j(topic = "c.TwoPhaseTermination")
class TwoPhaseTermination {
// 监控线程
private Thread monitorThread;
// 停止标记
private volatile boolean stop = false; //设置成volatile 因为只有一个线程对stop进行操作
// 判断是否执行过 start 方法
private boolean starting = false;
// 启动监控线程
public void start() {
synchronized (this) { //需要加锁防止多个线程同时改变starting状态
if (starting) { // false
return;
}
starting = true;
}
monitorThread = new Thread(() -> {
while (true) {
Thread current = Thread.currentThread();
// 是否被打断
if (stop) {
log.debug("料理后事");
break;
}
try {
Thread.sleep(1000);
log.debug("执行监控记录");
} catch (InterruptedException e) {
}
}
}, "monitor");
monitorThread.start();
}
// 停止监控线程
public void stop() {
stop = true;
monitorThread.interrupt();
}
}
1.3 、有序性-----指令重排序
测试:
@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {
int num = 0;
boolean ready = false;
@Actor
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
@Actor
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}
结果:
禁止重排序:
volatile boolean ready = false;//在ready上加volatile关键字可以保证ready=true以上的代码不被指令重排序
二、volatile原理
1、volatile保证可见性原理
volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
● 对 volatile 变量的写指令后会加入写屏障
● 对 volatile 变量的读指令前会加入读屏障
(1) 如何保证可见性
● 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中。
public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是 volatile 赋值带写屏障
// 写屏障
}
● 而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据。
public void actor1(I_Result r) {
// 读屏障
// ready 是 volatile 读取值带读屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
(2)如何保证有序性
● 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
●读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
(3). double-checked locking 问题
以著名的 double-checked locking 单例模式为例
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
if(INSTANCE == null) { // t2
// 首次访问会同步,而之后的使用没有 synchronized
synchronized(Singleton.class) {
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
但在多线程环境下,上面的代码是有问题的,getInstance 方法对应的字节码为:
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class cn/itcast/n5/Singleton
8: dup //复制对象的指针用于后面解锁
9: astore_0
10: monitorenter
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 // class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn
也许 jvm 会优化为:先执行 24,再执行 21。如果两个线程 t1,t2 按如下时间序列执行:
double-checked locking 解决
public final class Singleton {
private Singleton() { }
private static volatile Singleton INSTANCE = null;
public static Singleton getInstance() {
// 实例没创建,才会进入内部的 synchronized代码块
if (INSTANCE == null) {
synchronized (Singleton.class) { // t2
// 也许有其它线程已经创建实例,所以再判断一次
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
以上解决了:
① 使用synchronized 对Singleton.class进行了加synchronized 锁,解决了多个线程访问到 INSTANCE = new Singleton();会创建多个单例了。
② 对Singleton实例INSTANCE 加了volatile关键字从而保证不会指令重排。
小结:① synchronized 既能保证原子性、可见性、有序性,其中有序性是在该共享变量完全被synchronized 所接管(包括共享变量的读写操作),上面的例子中synchronized 外面的 if (INSTANCE == null) 中的INSTANCE读操作没有被synchronized 接管,因此无法保证INSTANCE共享变量的有序性(即不能防止指令重排)。
② 对共享变量加volatile关键字可以保证可见性和有序性,但是不能保证原子性(即不能防止指令交错)。
(4) happens-before
happens-before 规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下 happens-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 被打断后对变量的读可见(通过t2.interrupted 或 t2.isInterrupted)
具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z ,配合 volatile 的防指令重排,有下面的例子
(4) 线程安全单例习题
三、ThreadLocal的使用
ThreadLocal:用于实现线程内部的数据共享叫线程共享(对于同一个线程内部数据一致),即相同的一段代码 多个线程来执行 ,每个线程使用的数据只与当前线程有关。
实现原理:ThreadLocal相当于一个map 当前线程 存储当前的变量的时候 map.put(确定线程的唯一值(比如变量名称),变量),然后获取的时候直接拿过来就行
一般用法:定义一个全局变量ThreadLoacl t 将新建线程要使用的变量 存进去 比如:
1.当存储的为基本变量或者包装对象时
@Slf4j(topic = "c.testThreadLocal")
public class TestThreadLocal {
/*定义一个全局变量 来存放线程需要的变量*/
public static ThreadLocal<Integer> ti = new ThreadLocal<Integer>();
public static void main(String[] args) {
/*创建两个线程*/
for(int i=0; i<2;i++){
new Thread(new Runnable() {
@Override
public void run() {
Double d = Math.random()*10;
/*存入当前线程独有的值*/
ti.set(d.intValue());
new A().get();
new B().get();
}
}).start();
}
}
static class A{
public void get(){
/*取得当前线程所需要的值*/
System.out.println("A"+ti.get());
}
}
static class B{
public void get(){
/*取得当前线程所需要的值*/
System.out.println("B"+ti.get());
}
}
}
输出:
A5
B5
A4
B4
2.当存储的为对象时 就是数据集合 比如前台传过来的参数,每一个人传过来的 都是这个人独有的,才能保证数据准确性,抽取业务数据为一个对象:
class ThreadLocalDemo{
/*把线程相关的部分内聚到 类里面 相当于map 每个类是对应key*/
private static ThreadLocal<ThreadLocalDemo> t = new ThreadLocal<ThreadLocalDemo>();
private ThreadLocalDemo(){}
public static ThreadLocalDemo getThreadInstance(){
ThreadLocalDemo threadLocalDemo = t.get();
if(null == threadLocalDemo){//当前线程无绑定的对象时,直接绑定一个新的对象
threadLocalDemo = new ThreadLocalDemo();
t.set(threadLocalDemo);
}
return threadLocalDemo;
}
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
3、ThreadLocal 和 synchronized的比较
ThreadLocal
- 先说下 ThreadLocal不能解决多线程间共享数据,他是一个隔离多线程间共享数据的好帮手
- ThreadLocal是本地线程共享数据
- 他是以空间换线程的安全性(每个线程都有一个副本)
synchronized
- 解决多线程间共享数据安全的问题
- 他是以时间换空间(monitor锁)的方案,效率差(适用并发量小的时候)
注:
ThreadLocal和Synchonized都用于解决多线程并发访问。但是ThreadLocal与synchronized有本质的区别:
synchronized是利用锁的机制,使变量或代码块在某一时该只能被一个线程访问;
而ThreadLocal为每一个线程都提供了变量的副本,使得每个线程在某一时间访问到的并不是同一个对象,这样就隔离了多个线程对数据的数据共享。
而Synchronized却正好相反,它用于在多个线程间通信时能够获得数据共享。
Synchronized用于线程间的数据共享,而ThreadLocal则用于线程间的数据隔离。
欢迎关注公众号Java技术大联盟,会不定期分享BAT面试资料等福利。