文章内容
一、线程的安全问题
线程的安全性问题的本质其实就是并发造成的,举个例子,就好比一对夫妻两个人同时对一个银行账户进行操作,这个银行账户如果只有100块钱,当丈夫去取了100的时候,这么一瞬间,银行还没有扣款,妻子也取了100块,这个时候岂不是银行亏了啊。这就是安全性问题。
安全性问题其实就三种:
1、原子性问题
代码举例:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | public class AtomicDemo { public static int count= 0 ; //100 public static void add(){ try { Thread.sleep( 1 ); } catch (InterruptedException e) { e.printStackTrace(); } count++; } public static void main(String[] args) { for ( int i= 0 ;i< 1000 ;i++){ new Thread(()-> AtomicDemo.add()).start(); } try { Thread.sleep( 3000 ); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println( "运行结果:" +count); } } |
通过javap -v AtomicDemo.class 查看二进制字节码
01 02 03 04 05 06 07 08 09 10 11 | 12: getstatic #5 // Field count:I 15: iconst_1 16: iadd 17: putstatic #5 #invokestatic:调用静态方法 #invokespecial:调用实例构造器方法、私有方法和父类方法 #invokevirtual: 调用虚方法,运行期动态查找的过程。 #invokedynamic: 动态调用方法。 #invokeinterface:调用接口中的方法,实际上是在运行期决定的,决定到底调用实现该接口的那个对象的特定方法 1.静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法,其他方法都是虚方法。 |
2、可见性问题
代码举例:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 | public class VolatileDemo01 { public static boolean stop = false ; public static void main(String[] args) throws InterruptedException { Thread thread = new Thread(() -> { int i = 0 ; while (!stop) { i++; } System.out.println( "rs:" + i); }); thread.start(); Thread.sleep( 10000 ); int a= 0 ; stop = true ; } } |
3、有序性问题
代码举例:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | public class OrderDemo { private static int i = 0 ; public static void main(String[] args) throws InterruptedException { for (;;){ new Thread(()->{ add(); }).start(); new Thread(()->{ dec(); }).start(); System.out.println(i); } } public static void add(){ i++; } public static void dec(){ i--; } } |
二、Synchronized
一个从根源上解决问题的男人,它将并发操作限制成了串行操作
1、怎么使用
synchronized有三种方式来加锁,不同的修饰类型,代表锁的控制粒度:
- 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
- 静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
- 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁
2、存储在哪
- 在我们最常用的虚拟机 hotspot 中对象在内存中的分布可以分为三个部分:对象头(Header)、实列数据(Instance Data)、对其填充(Padding)
![并发编程之线程安全插图 并发编程之线程安全插图](https://static.ntan520.com/blog-media/2022/10/82eb3b86f794f00027a18a4d6ea082c1.png?x-oss-process=style/watermark)
- 类元信息是用来存这个对象是属于哪个类的
- 实例数据就是实例数据(嘿嘿)
- 对齐填充是为了保证这个对象的长度一定是8字节(32位)或者16字节(64位)的整数倍
![并发编程之线程安全插图2 并发编程之线程安全插图2](https://static.ntan520.com/blog-media/2022/10/a11fd88309a27126b36828768bad34e5.png?x-oss-process=style/watermark)
3、升级流程
![并发编程之线程安全插图4 并发编程之线程安全插图4](https://static.ntan520.com/blog-media/2022/10/ee197b2f20ff9776ef270a5af1fb54de.png?x-oss-process=style/watermark)
- 1. 锁的状态总共有四种,级别由低到高依次为:无锁、偏向锁、轻量级锁、重量级锁
- 2. 在JDK6.0之前(不包括1.6),它是重量级,1.6引用锁升级的概念
- 在 synchronized最初的实现方式是 “阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,如果同步代码块中内容过于简单,这种切换的时间可能比用户代码执行的时间还长”,这也是在JDK6以前synchronized效率低下的原因。
- JDK 1.6后,Jvm对synchronized 进行了优化,引入了偏向锁和轻量级锁 ,从此以后锁的状态就有了四种(无锁、偏向锁、轻量级锁、重量级锁),并且四种状态会随着竞争的情况逐渐升级,而且是不可逆的过程,即不可降级,也就是说只能进行锁升级(从低级别到高级别),不能锁降级(高级别到低级别)。
![并发编程之线程安全插图6 并发编程之线程安全插图6](https://static.ntan520.com/blog-media/2022/10/8b5e9fd751b20082f228898f772ecf55.png?x-oss-process=style/watermark)
1)偏向锁
加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距,如果线程间存在锁竞争,会带来额外的锁撤销的消耗,但是只会竞争一次,所以适用在一个线程访问同步代码块,但是这样的业务估计就没有,所以默认也关闭了,因为就算有,也都会上升到轻量级锁。
在java15中已经默认禁用了偏向锁:https://openjdk.org/projects/jdk/15/
![并发编程之线程安全插图8 并发编程之线程安全插图8](https://static.ntan520.com/blog-media/2022/10/8bf779b74540f797e90c4b4c7b2768c8.png?x-oss-process=style/watermark)
2)轻量级锁
- 当代码执行到同步代码块时,如果同步对象没有被锁定也就是锁标志位为01 状态,那么虚拟机首先将在当前线程的栈帧中建立一个名为锁记录Lock Record 的空间
- 这块锁记录空间用来存储锁对象目前的 Mark Word 的拷贝,如下图所示,这是在CAS 操作之前堆栈与对象的状态
![并发编程之线程安全插图10 并发编程之线程安全插图10](https://static.ntan520.com/blog-media/2022/10/ecc945edc2c3ed72615c7e5dfdaf869b.png?x-oss-process=style/watermark)
- 当复制结束后虚拟机会通过CAS 操作尝试把对象的Mark Word 更新为指向Lock Record 的指针,如果更新成功则代表该线程拥有了这个对象的锁,并且将Mark Word 的锁标志位(最后两个比特)转变为 “00”,此时表示对象处于轻量级锁定状态,此时的堆栈与对象头的状态如下:
![并发编程之线程安全插图12 并发编程之线程安全插图12](https://static.ntan520.com/blog-media/2022/10/de77ee77c20fbe4f0aefa20f098f259b.png?x-oss-process=style/watermark)
- 如果上述操作失败了,那说明至少存在一条线程与当前线程竞争获取该对象的锁,虚拟机会首先检查对象的Mark Word 是否指向当前线程的栈帧,如果是,则说明当前线程已经拥有了这个对象的锁,那么直接进入同步代码块执行即可。否则则说明这个对象已经被其他线程抢占了。
- 如果有超过两条以上的线程争夺同一个锁的情况,那么轻量级锁就不再有效,必须膨胀为重量级锁,锁的标记位也变为“10”,此时Mark Word 中存储的就是指向重量级锁的指针,等待的线程也必须进入阻塞状态
那么轻量级锁针对于偏向锁的主要不是之前的一次竞争,而是会自旋竞争,所以会消耗cpu,但是比起后面的重量级锁性能还是快很多,毕竟不会阻塞线程
在jdk1.6以前,默认轻量级锁自旋次数是10次,如果超过这个次数或自旋线程数超过CPU核数的一半,就会升级为重量级锁!当然次数也可以更改,-XX:PreBlockSpin
jdk1.6以后加入了自适应自旋锁,自旋的次数不再固定,由jvm自己控制,由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:
- 对于某个锁对象,如果自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而允许自旋等待持续相对更长时间。
- 对于某个锁对象,如果自旋很少成功获得过锁,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
轻量级锁使用自旋到一定次数后还没有拿到锁,10次,就会升级为重量级锁,线程阻塞
3)重量级锁
![并发编程之线程安全插图14 并发编程之线程安全插图14](https://static.ntan520.com/blog-media/2022/10/5bde20b6f1cac199042eee64594d2951.png?x-oss-process=style/watermark)
- 重量级锁也就是上述几种优化都无效后,膨胀为重量级锁,通过互斥量来实现,我们先来看下面的代码
- 上面代码是一个简单使用了synchronized 的代码,我们通过字节码工具可以看到右侧窗口。我们发现,在同步代码块的前后分别形成了monitorenter 和 monitorexit 两条指令
- 在Java对现中都会有一个monitor 的监视器,这里的monitorenter 指令就是去获取一个对象的监视器。而相应的monitorexit 则表示释放监视器monitor 的所有权,允许被其他线程来获取
- monitor 是依赖于系统的 MutexLock (互斥锁) 来实现的,当线程阻塞后进入内核态事,就会造成系统在用户态和内核态之间的切换,进而影响性能
- 重量级对象hashcode存储在ObjectMonitor
三、锁的问题
1、死锁
1)死锁
死锁:是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。
这四个条件必须同时满足才会出现死锁:
- 互斥:共享资源 X 和 Y 只能被一个线程占用;
- 占有且等待:线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
- 不可抢占:其他线程不能强行抢占线程 T1 占有的资源;
- 循环等待:线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
2)活锁
活锁:任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试,失败,尝试,失败。活锁和死锁的区别在于,处于活锁的实体是在不断的改变状态,所谓的“活”, 而处于死锁的实体表现为等待;活锁有可能自行解开,死锁则不能。
3)饥饿
饥饿:一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行的状态。
Java 中导致饥饿的原因:
- 高优先级线程吞噬所有的低优先级线程的 CPU 时间。
- 线程被永久堵塞在一个等待进入同步块的状态,因为其他线程总是能在它之前持续地对该同步块进行访问。
- 线程在等待一个本身也处于永久等待完成的对象(比如调用这个对象的 wait 方法),因为其他线程总是被持续地获得唤醒。
2、线程通信
- 1. wait/notify notifyall
- 2. lockSupport.park/unpark
- 3. condition
- 4. volatile
- 5. coutdownlatch
四、Volatile
说白了就两个作用,解决可见性问题和有序性问题
上面讲线程问题的时候讲到了可见性问题,那怎么解决呢?
代码演示:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | public class VolatileDemo02 { // public static boolean stop = false; public volatile static boolean stop = false ; //3、加个volatile也可以结束 public static void main(String[] args) throws InterruptedException { Thread thread = new Thread(() -> { int i = 0 ; while (!stop) { i++; // try { // Thread.sleep(5); // } catch (InterruptedException e) { // e.printStackTrace(); // }//2、睡眠一下也可以结束 // System.out.println("rs:"+i);//1、打印放在循环里面也可以结束 } System.out.println( "rs:" + i); }); thread.start(); Thread.sleep( 1000 ); stop = true ; } } |
代码里面提供了三种方案:
- 1. 打印放在循环里面也可以结束
- 里面存在synchronized关键字
- 2. 循环里面睡眠一下也能结束
- 导致时间片主动释放,释放后线程进入到等待状态,下次获得时间片之后,缓存数据会与内存同步。
- 3. 通过volatile关键字
1、硬件层面
1)CPU计算速度>>内存IO速度>>IO设备IO速度
原始问题:CPU计算速度>>内存IO速度>>IO设备IO速度。
解决方案:为了解决速度不匹配问题,硬件工程师设计了高速缓存
![并发编程之线程安全插图16 并发编程之线程安全插图16](https://static.ntan520.com/blog-media/2022/10/74a29cb1810c15ce184b6403a3ec63d9.jpg?x-oss-process=style/watermark)
缓存包含L1(L1d-数据缓存(存放数据)、L1i指令缓存(执行数据的指令码))、L2、L3,其中L3是CPU共享,而L1和L2是每个cpu独占的缓存空间。这三级缓存都是集成在CPU内的缓存结构,他们的作用就是为CPU和主内存之间提供一个高速缓冲区,L1最靠近CPU核心;L2其次;L3再次。运行速度方面:L1最快、L2次快、L3最慢;容量大小方面:L1最小、L2较大、L3最大。CPU会先在最快的L1中寻找需要的数据,找不到再去找次快的L2,还找不到再去找L3,L3都没有那就只能去内存找了。
2)可见性问题
导致问题:可见性问题
解决方案:总线锁和缓存锁(MESI)
- 1. M(Modify) 被修改 表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致。
- 2. E(Exclusive) 独享的 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
- 3. S(Shared) 共享的 表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致
- 4. I(Invalid) 无效的 表示缓存已经失效,不过读或者写都要从主内存和别的缓存读取数据。
3)性能问题
导致问题:性能问题
解决方案:写缓存(Store Buffer)跟无效化队列(Invalidate Queue),使得活儿又变成了异步操作
导致问题:指令重排
![并发编程之线程安全插图18 并发编程之线程安全插图18](https://static.ntan520.com/blog-media/2022/10/5df0a5ec8adcf1f254b248f1dfe6853c.png?x-oss-process=style/watermark)
1 2 3 4 5 6 7 8 9 | executeToCPU0(){ a= 1 ; b= 1 ; } executeToCPU1(){ while (b== 1 ){ assert (a== 1 ) } } |
4)重排序
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从java源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel称之为memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。
![并发编程之线程安全插图20 并发编程之线程安全插图20](https://static.ntan520.com/blog-media/2022/10/eff0b12f98e98220e814c95ca298a7d9.png?x-oss-process=style/watermark)
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
5)指令重排问题
指令重排问题来了
解决方案:没办法了,整来整去全是问题,回到最开始的MESI算了,通过内存屏障来禁用写缓存和无效队列
写屏障(Store Memory Barrier):强制将Store Buffer中的内容写入到缓存中或者将该指令之后的写操作写入store buffer直到之前的内容被刷入到缓存中,也被称之为smp_wmb 读屏障(Load Memory Barrier):强制将Invalidate Queue中的内容处理完毕,也被称之为smp_rmb
全屏障(Fence Memory Barrier)
2、软件层面
1)volatile的作用
- 1. 禁止编译器对代码进行某些优化
- 2. lock汇编指令,锁住缓存行,启动内存屏障,禁止指令重排,保证有序性与可见性
当然,除了volatile来保证可见性外,我们从JDK1.5开始就引用了happens-before的概念来保证可见性!!
2)Happens-Before原则
happens-before是JMM定义的2个操作之间的偏序关系:如果操作A线性发生于操作B,则A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。如果两个操作满足happens-before原则,那么不需要进行同步操作,JVM能够保证操作具有顺序性,此时不能够随意的重排序。否则,无法保证顺序性,就能进行指令的重排序。
五、Happens-Before原则
1、程序顺序规则
一个线程中的每个操作,happens-before这个线程中的任意后续操作,可以简单认为是as-if-serial。as-if-serial的意思是,不管怎么重排序,单线程的程序的执行结果不能改变。
- 处理器不能对存在依赖关系的操作进行重排序,因为重排序会改变程序的执行结果。
- 对于没有依赖关系的指令,即便是重排序,也不会改变在单线程环境下的执行结果。
具体来看下面这段代码,A和B允许重排序,但是C是不允许重排,因为存在依赖关系。根据as-if-serial语义,在单线程环境下, 不管怎么重排序,最终执行的结果都不会发生变化。
1 2 3 | int a= 2 ; //A int b= 2 ; //B int c=a*b; //C |
2、传递性规则
仍然看下面这段代码,根据程序顺序规则可以知道,这三者之间存在一个happens-before关系。
1 2 3 | int a= 2 ; //A int b= 2 ; //B int c=a*b; //C |
- A happens-before B。
- B happens-before C。
- A happens-before C。
这三个happens-before关系,就是根据happens-before的传递性推导出来的。很多同学这个时候又有疑惑了,老师,你不是说,A和B之间允许重排序吗?那是不是A happens-before B不一定存在,也可能是B可以重排序在A之前执行呢?
没错,确实是这样,JMM不要求A一定要在B之前执行,但是他要求的是前一个操作的执行结果对后一个操作可见。这里操作A的执行结果不需要对操作B可见,并且重排序操作A和操作B后的执行结果与A happens-before B顺序执行的结果一致,这种情况下,是允许重排序的。
3、volatile变量规则
对于volatile修饰的变量的写操作,一定happens-before后续对于volatile变量的读操作,这个是因为volatile底层通过内存屏障机制防止了指令重排,这个规则前面已经分析得很透彻了,所以没什么问题,我们再来观察如下代码,基于前面两种规则再结合volatile规则来分析下面这个代码的执行顺序,假设两个线程A和B,分别访问writer方法和reader方法,那么它将会出现以下可见性规则。
01 02 03 04 05 06 07 08 09 10 11 12 13 | public class VolatileExample { int a= 0 ; volatile boolean flag= false ; public void writer(){ a= 1 ; //1 flag= true ; //2 } public void reader(){ if (flag){ //3 int i=a; //4 } } } |
- 1 happens before 2、 3 happens before 4, 这个是程序顺序规则
- 2 happens before 3、 是由volatile规则产生的,对一个volatile变量的读,总能看到任意线程对这个volatile变量的写入。
- 3 happens before 4, 基于传递性规则以及volatile的内存屏障策略共同保证。
那么最终结论是,如果在线程B执行reader方法时,如果flag为true,那么意味着 i=1成立。
![并发编程之线程安全插图22 并发编程之线程安全插图22](https://static.ntan520.com/blog-media/2022/10/de00da9596d6441a47f94c38706d68b2.jpg?x-oss-process=style/watermark)
这里有同学可能会有疑问说,老师,你前面讲的程序顺序规则中,在单线程中,如果两个指令之间不存在依赖关系,是允许重排序的,也就是1 和 2的顺序可以重排,那么是不是意味着最终4输出的结果是0呢?
这里也是因为volatile修饰的重排序规则的存在,导致1和2是不允许重排序的,在volatile重排序规则表中,如果第一操作是普通变量的读/写,第二个操作是volatile的写,那么这两个操作之间不允许重排序。
![并发编程之线程安全插图24 并发编程之线程安全插图24](https://static.ntan520.com/blog-media/2022/10/35212216f25088fa67d9dc4a31c6defd.png?x-oss-process=style/watermark)
4、监视器锁规则
一个线程对于一个锁的释放锁操作,一定happens-before与后续线程对这个锁的加锁操作。
1 2 3 4 5 6 7 | int x= 10 ; synchronized ( this ) { // 此处自动加锁 // x 是共享变量, 初始值 =10 if ( this .x < 12 ) { this .x = 12 ; } } // 此处自动解锁 |
假设x的初始值是10,线程A执行完代码块后,x的值会变成12,执行完成之后会释放锁。 线程B进入代码块时,能够看到线程A对x的写操作,也就是B线程能够看到x=12。
5、start规则
如果线程A执行操作ThreadB.start(),那么线程A的ThreadB.start()之前的操作happens-before线程B中的任意操作。
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 | public class Test{ public static void main(String[] args){ final int x[]={ 0 }; Thread t1 = new Thread(()->{ // 主线程调用 t1.start() 之前 // 所有对共享变量的修改,此处皆可见 // 此例中,x==10 System.out.println(x[ 0 ]); }); // 此处对共享变量 x修改 x[ 0 ] = 10 ; // 主线程启动子线程 t1.start(); } } |
6、join规则
join规则,如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功的返回。
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 | public class Test{ public static void main(String[] args){ final int [] x = { 0 }; Thread t1 = new Thread(()->{ // 此处对共享变量 x 修改 x[ 0 ] = 100 ; }); // 例如此处对共享变量修改, // 则这个修改结果对线程 t1 可见 // 主线程启动子线程 t1.start(); try { t1.join(); } catch (InterruptedException e) { e.printStackTrace(); } // 子线程所有对共享变量的修改 // 在主线程调用 t1.join() 之后皆可见 // 此例中,x==100 System.out.println(x[ 0 ]); } } |