多线程&锁
多线程&锁
JMM 内存模型
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成。
JMM 同步八种操作介绍
- lock「锁定」:作用于主内存的变量,把一个变量标记为一条线程独占状态;
- unlock「解锁」:作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;
- read「读取」:作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的 load 动作使用;
- load「载入」:作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中;
- use「使用」:作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎;
- assign「赋值」:作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量;
- store「存储」:作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中, 以便随后的write的操作;
- write「写入」:作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中;
上面八步的操作如下,需要保证的是操作必须按顺序,但是没有必要保证是连续的。
并发编程的三个特性,也是需要 JMM 来解决
- 原子性
- 可见性
- 有序性
-
原子性:即多步操作需要全部执行成功,或者全部执行不成功;
-
可见性:在多线程环境下,会存在一些共享变量,某个线程对共享变量的修改,其他线程能够立马感知到,并将最新的值更新到自己的工程内存中;
-
有序性:因为 CPU 会对编译好的程序进行指令重排,在单线程下没有影响,但是在多线程下,可能会导致异常;
指令重排序:
Java 语言规范规定 JVM 线程内部维持顺序化语义。即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。
指令重排序的意义是什么?
JVM 能根据处理器特性(CPU多级缓存系统、多核处理器等) 适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。
synchronized 和 lock 能保证原子性和可见性,不能保证有序性;
volatile 能保证可见性和有序性,不能保证原子性;
as-if-serial语义:
不管怎么重排序(编译器和处理器为了提高并行度),(单线 程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守 as-if-serial 语义
happens-before 原则:
- 程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行;
- 锁规则解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说, 如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁);
- volatile 规则 volatile 变量的写,先发生于读,这保证了 volatile 变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,在任何时刻,不同的线程总是能够看到该变量的最新值;
- 线程启动规则线程的 start() 方法先于它的每一个动作,即如果线程 A 在执行线程 B 的 start 方法之前修改了共享变量的值,那么当线程 B 执行 start 方法时,线程 A 对共享变量的修改对线程 B 可见;
- 传递性 A 先于 B ,B 先于 C 那么 A 必然先于 C;
- 线程终止规则线程的所有操作先于线程的终结,Thread.join() 方法的作用是等待当前执行的线程终止。假设在线程 B 终止之前,修改了共享变量,线程 A 从线程 B 的 join 方法 成功返回后,线程 B 对共享变量的修改将对线程 A 可见;
- 线程中断规则对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中 断事件的发生,可以通过 Thread.interrupted() 方法检测线程是否中断;
- 对象终结规则对象的构造函数执行,结束先于 finalize() 方法。
synchronized
概述:
synchronized 内置锁是一种对象锁(锁的是对象而非引用),作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的。
原理:
基于 JVM 内置锁实现的,通过内部的 Monitor 去实现自动加锁和释放锁,Monitor 底层依赖的是互斥锁 「Mutex lock」,在 jdk1.5 之前,性能比较低,在 jdk1.5 之后,进行一系列的优化,比如:锁粗化、锁消除、轻量级锁、偏向锁、适应性自旋等手段来减少锁操作的开销。目前 synchronized 的性能和 Lock 基本持平。
既然是以对象作为锁,那么对象是怎么进行记录锁的?
下面是对象的内存分布:
在 HotSpot 虚拟机中,对象在内存中存储的布局分为三块区域:
- 对象头
- 实例数据
- 对齐填充
- 对象头:
- Mark Word「用于存储对象自身的运行时数据,如哈希码,GC 分代年龄、锁状态标志、持有该锁的线程 id、偏向线程 id、偏向时间戳等等」
volatile 是通过内存屏障来保证有序性和可见性;
内存屏障:
- 禁止 CPU 进行指令重排;
- 强制将工作内存的改动刷新到主内存中;
volatile 禁止指令重排:
锁粗化&锁消除
https://blog.csdn.net/qq_26222859/article/details/80546917
-
锁粗化的案例:循环中多次获取同一个锁,可将获取锁的代码放在循环的外部,这样就只用获取一次锁;
-
锁消除的案例:锁消除是发生在编译器级别的一种锁优化方式,有时候我们写的代码完全不需要加锁,却执行了加锁操作,比如:StringBuffer 类的 append 操作;
锁粗化和锁消除都是 JIT 编译器自己做的优化,锁粗化是将多次获取锁的操作换为一次锁操作,锁消除是根据逃逸分析来的,如果对象不会发生逃逸,就可以进行锁消除。
锁的膨胀升级「从低到高」
- 无锁状态
- 偏向锁
- 轻量级锁
- 重量级锁
下面是 JVM 锁的膨胀升级:
CAS 以及 CAS 在 Java 中应用:
cas 用于解决多线程并发安全的问题,以前我们对一些多线程操作的代码都是使用 synchronize 关键字,来保证线程安全的问题,使用 cas 的话就是避免了加锁的耗时操作。
cas 是 compare and swap的简称,从字面上理解就是比较并更新,简单来说:从某一内存上取值 V,和预期值 A 进行比较,如果内存值 V 和预期值 A 的结果相等,那么我们就把新值 B 更新到内存,如果不相等,那么就重复上述操作直到成功为止。
Atomic 系列就是使用 cas 实现的,比如 AtomicInteger 中使用volatile 修饰的 value。
CAS 的隐患:
1、首先就是经典的 ABA 问题
何为 ABA 呢?我们还是以两个线程 L、N 进行自增操作为例,线程 L、N 同时获取当前的值 A,只不过此时线程 N 比较快,它在 L 操作之前,进行了两次操作,第一次将值从 A 改为了 B,之后又将 B 改为了 A ,那么在线程 L 操作的 时候发现当前的值还是 A,符合预期,那么它也会更新成功,从操作上看并没有什么不对,更新成功也是对的,但是这样是有隐患的。
2、长时间自旋非常消耗资源
先说一下什么叫自旋,自旋就是 cas 的一个操作周期,如果一个线程特别倒霉,每次获取的值都被其他线程的修改了,那么它就会一直进行自旋比较,直到成功为止,在这个过程中 cpu 的开销十分的大,所以要尽量避免。
如何结局 ABA 问题
采用数据版本号的方案,也就是 AtomicInteger 类中的 valueOffset 属性。