目录:
一.volatile的实现原理
二.synchronized的实现原理与应用
二.原子操作的实现原理
一.volatile的定义与实现
Java语言提供了一种稍弱的同步机制,即volatile变量,用来确保将变量的更新操作通知到其他线程。当把变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。
当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中。如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中。
而声明变量是 volatile 时,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步。
1.1 volatile 定义
1.1.1、volatite特性
· 可见性
能够保证线程可见性,当一个线程修改共享变量时,能够保证对另外一个线程可见性。
· 顺序性
程序执行程序按照代码的先后顺序执行。
· 防止指令重排序
通过插入内存屏障在cpu层面防止乱序执行。
有volatile修饰的变量,赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障;(什么是指令重排序:是指CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理)。
1.1.2、volatile可见性
public class VolatileTest extends Thread {
/**
* volatile关键字底层通过 汇编 lock指令前缀 强制修改值,
* 并立即刷新到主内存中,另外一个线程可以马上看到刷新的主内存数据
*/
private static volatile boolean FLAG = true;
@Override
public void run() {
while (FLAG) {
try {
TimeUnit.MILLISECONDS.sleep(300);
System.out.println("==== test volatile ====");
} catch (InterruptedException ignore) {
}
}
}
public static void main(String[] args) throws InterruptedException {
new VolatileTest().start();
TimeUnit.SECONDS.sleep(1);
FLAG = false;
}
}
1.1.3、volatile 的实现原理
在 Java 中我们可以直接使用 volatile 关键字,被 volatile 变量修饰的共享变量进行写操作的时候会多生成一行汇编代码,这行代码使用了 Lock 指令。Lock 指令在多核处理器下会引发两件事情:
· 1、将当前处理器缓存行的数据写回到系统内存。
· 2、这个写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存后再进行操作,但操作完后不知道何时会写到内存。如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 Lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但其他处理器的缓存还是旧值,为了保证各个处理器的缓存是一致的,每个处理器会通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了。当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作时,会重新从系统内存中把数据读到处理器缓存里。
1.1.4、volatile 性能
volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
1.1.5、volatile 的应用
volatile 在多处理器开发中保证了共享变量的可见性。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能立即读取到修改过后的值。
1.2、volatile原理分析
1.2.1、CPU多核硬件架构剖析
CPU的运行速度非常快,而对磁盘的读写IO速度却很慢,为了解决这个问题,有了内存的诞生;而CPU的速度与内存的读写速度之比仍然有着100 : 1的差距,为了解决这个问题,CPU又在内存与CPU之间建立了多级别缓存:寄存器、L1、L2、L3三级缓存。
1.2.2、产生可见性的原因
因为我们CPU读取主内存共享变量的数据时候,效率是非常低,所以对每个CPU设置对应的高速缓存 L1、L2、L3 缓存我们共享变量主内存中的副本。
相当于每个CPU对应共享变量的副本,副本与副本之间可能会存在一个数据不一致性的问题。比如线程B修改的某个副本值,线程A的副本可能不可见,导致可见性问题。
1.2.3、JMM内存模型
Java内存模型定义的是一种抽象的概念,定义屏蔽java程序对不同的操作系统的内存访问差异。
主内存:存放我们共享变量的数据
工作内存:每个CPU对共享变量(主内存)的副本。堆+方法区
1.2.4、JMM八大同步规范
· 1、lock(锁定):作用于 主内存的变量,把一个变量标记为一条线程独占状态。
· 2、unlock(解锁):作用于 主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
· 3、read(读取):作用于 主内存的变量,把一个变量值从主内存传输到线程的 工作内存中,以便随后的load动作使用。
· 4、load(载入):作用于 工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
· 5、use(使用):作用于 工作内存的变量,把工作内存中的一个变量值传递给执行引擎。
· 6、assign(赋值):作用于 工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量。
· 7、store(存储):作用于 工作内存的变量,把工作内存中的一个变量的值传送到 主内存中,以便随后的write的操作。
· 8、write(写入):作用于 工作内存的变量,它把store操作从工作内存中的一个变量的值传送到 主内存的变量中。
JMM对这八种指令的使用,制定了如下规则:
· 不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write。
· 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存。
· 不允许一个线程将没有assign的数据从工作内存同步回主内存。
· 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施use、store操作之前,必须经过assign和load操作。
· 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁。
· 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值。
· 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量。
· 对一个变量进行unlock操作之前,必须把此变量同步回主内存。
JMM对这八种操作规则和对volatile的一些特殊规则,就能确定哪里操作是线程安全,哪些操作是线程不安全的了。但是这些规则实在复杂,很难在实践中直接分析。所以一般我们也不会通过上述规则进行分析。更多的时候,使用java的happen-before规则来进行分析。
Happen-Before(先行发生规则)
在常规的开发中,如果我们通过上述规则来分析一个并发程序是否安全,估计脑壳会很疼。因为更多时候,我们是分析一个并发程序是否安全,其实都依赖Happen-Before原则进行分析。Happen-Before被翻译成先行发生原则,意思就是当A操作先行发生于B操作,则在发生B操作的时候,操作A产生的影响能被B观察到,“影响”包括修改了内存中的共享变量的值、发送了消息、调用了方法等。
Happen-Before的规则有以下几条
· 程序次序规则(Program Order Rule):在一个线程内,程序的执行规则跟程序的书写规则是一致的,从上往下执行。
· 管程锁定规则(Monitor Lock Rule):一个Unlock的操作肯定先于下一次Lock的操作。这里必须是同一个锁。同理我们可以认为在synchronized同步同一个锁的时候,锁内先行执行的代码,对后续同步该锁的线程来说是完全可见的。
· volatile变量规则(volatile Variable Rule):对同一个volatile的变量,先行发生的写操作,肯定早于后续发生的读操作。
· 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
· 线程中止规则(Thread Termination Rule):Thread对象的中止检测(如:Thread.join(),Thread.isAlive()等)操作,必须晚于线程中所有操作。
· 线程中断规则(Thread Interruption Rule):对线程的interruption()调用,先于被调用的线程检测中断事件(Thread.interrupted())的发生。
· 对象中止规则(Finalizer Rule):一个对象的初始化方法先于一个方法执行Finalizer()方法。
· 传递性(Transitivity):如果操作A先于操作B、操作B先于操作C,则操作A先于操作C。
以上就是Happen-Before中的规则。通过这些条件的判定,仍然很难判断一个线程是否能安全执行,毕竟在我们的时候线程安全多数依赖于工具类的安全性来保证。想提高自己对线程是否安全的判断能力,必然需要理解所使用的框架或者工具的实现,并积累线程安全的经验。
1.2.5、volatile汇编lock指令前缀
· 1、将当前处理器缓存行数据立刻写入主内存中。
· 2、写的操作会触发总线嗅探机制,同步更新主内存的值。
1.2.6、通过Idea工具查看java汇编指令
1. jdk安装包\jre\bin\server 放入 hsdis-amd64.dll
2. idea 配置 VM options, 最后一个参数 xxxxx. 就是一个我们的需要查看汇编的class类
-server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly,*VolatileTest.*
3 .查看结果 ,会发现在 volatile 关键字 修饰的变量,在写操作时,对应的汇编指令,都有一个lock指令前缀
1.3、volatile的底层实现原理
通过汇编lock前缀指令触发底层锁的机制,锁的机制两种:总线锁/MESI缓存一致性协议,主要帮助我们解决多个不同cpu之间缓存之间数据同步的问题。
1.3.1、总线锁
当一个cpu(线程)访问到我们主内存中的数据时候,往总线总发出一个Lock锁的信号,其他的线程不能够对该主内存做任何操作,变为阻塞状态。该模式,存在非常大的缺陷,就是将并行的程序,变为串行,没有真正发挥出cpu多核的好处。
1.3.2、MESI协议
· 1、M 修改 (Modified) 这行数据有效,数据被修改了,和主内存中的数据不一致,数据只存在于本Cache中。
· 2、E 独享、互斥 (Exclusive) 这行数据有效,数据和主内存中的数据一致,数据只存在于本Cache中。
· 3、S 共享 (Shared) 这行数据有效,数据和主内存中的数据一致,数据存在于很多Cache中。
· 4、I 无效 (Invalid) 这行数据无效。
E: 独享:
当只有一个cpu线程的情况下,cpu副本数据与主内存数据如果,保持一致的情况下,则该cpu状态为E状态 独享。
S: 共享:
在多个cpu线程的情况了下,每个cpu副本之间数据如果保持一致的情况下,则当前cpu状态为S。
M: 修改:
如果当前cpu副本数据如果与主内存中的数据不一致的情况下,则当前cpu状态为M。
I: 无效:
总线嗅探机制发现 状态为m的情况下,则会将该cpu改为i状态 无效。
如果状态是M的情况下,则使用嗅探机制通知其他的CPU工作内存副本状态为I无效状态,则刷新主内存数据到本地中,从而多核cpu数据的一致性。
该cpu缓存主动获取主内存的数据同步更新。
总线:维护解决cpu高速缓存副本数据之间一致性问题。
1.4、volatile不能保证原子性原因
public class VolatileTest extends Thread {
private static volatile int count = 0;
public static void add() {
count++;
}
public static void main(String[] args) throws InterruptedException {
ArrayList<Thread> threads = new ArrayList<>();
for (int i = 0; i < 100; i++) {
Thread test = new Thread(() -> {
for (int k = 0; k < 1000; k++) {
add();
}
});
threads.add(test);
test.start();
}
threads.forEach(v -> {
try {
v.join();
} catch (InterruptedException ignore) {
}
});
System.out.println("<><><><> count: " + count);
}
}
volatile为了能够保证数据的可见性,但是不能够保证原子性,及时的将工作内存的数据刷新主内存中,导致其他的工作内存的数据变为无效状态,其他工作内存做的count++操作等于就是无效丢失了,这是为什么我们加上Volatile count结果在小于100000以内。
1.5、volatile存在的伪共享的问题
CPU会以缓存行的形式读取主内存中数据,缓存行的大小为2的幂次数字节,一般的情况下是为64个字节。如果该变量共享到同一个缓存行,就会影响到整理性能。
例如:线程1修改了long类型变量A,long类型定义变量占用8个字节,在由于缓存一致性协议,线程2的变量A副本会失效,线程2在读取主内存中的数据的时候,以缓存行的形式读取,无意间将主内存中的共享变量B也读取到内存中,而该主内存中的变量B没有发生变化。
解决缓存行伪共享问题 ,使用缓存行填充方案避免伪共享。
@sun.misc.Contended
可以直接在类上加上该注解@sun.misc.Contended,启动的时候需要加上该参数-XX:-RestrictContended,该方案在JDK8有效,JDK12中被优化掉了。
例如 ConcurrentHashMap中的CounterCell,就是使用了缓存行填充方案避免为共享
1.6、JMM中的重排序及内存屏障
public class ReorderThread {
private static int a, b, x, y;
public static void main(String[] args) throws InterruptedException {
int i = 0;
while (true) {
i++;
a = 0;
b = 0;
x = 0;
y = 0;
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
a = 1;
x = b;
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
y = a;
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("第" + i + "次(" + x + "," + y + ")");
if (x == 0 & y == 0) {
break;
}
}
}
}
当我们的CPU写入缓存的时候发现缓存区正在被其他cpu站有的情况下,为了能够提高CPU处理的性能可能将后面的读缓存命令优先执行。注意:不是随便重排序,需要遵循as-ifserial语义。
as-ifserial:不管怎么重排序(编译器和处理器为了提高并行的效率)单线程程序执行结果不会发生改变的。也就是我们编译器与处理器不会对存在数据依赖的关系操作做重排序。
CPU指令重排序优化的过程存在问题
as-ifserial 单线程程序执行结果不会发生改变的,但是在多核多线程的情况下,指令逻辑无法分辨因果关系,可能会存在一个乱序中心问题,导致程序执行结果错误。
如同上面图,所示会出现会有机会两个线程中,A线程执行顺序1逻辑,而B线程执行顺序2逻辑。
1.6.1、内存屏障解决重排序
处理器提供了两个内存屏蔽指令,解决以上存在的问题
· 1.写内存屏障:在指令后插入Stroe Barrier,能够让写入缓存中的最新数据更新写入主内存中,让其他线程可见。这种强制写入主内存,这种现实调用CPU就不会因为性能的考虑对指令重排序。
· 2.读内存屏障:在指令前插入load Barrier ,可以让告诉缓存中的数据失效,强制从新主内存加载数据强制读取主内存,让CPU缓存与主内存保持一致,避免缓存导致的一致性问题。
1.6.2、手动插入内存屏障
public class ReorderThread {
private static int a, b, x, y;
public static void main(String[] args) throws InterruptedException {
int i = 0;
while (true) {
i++;
a = 0;
b = 0;
x = 0;
y = 0;
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
a = 1;
// 添加写屏障
ReorderThread.getUnsafe().storeFence();
x = b;
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
b = 1;
// 添加写屏障
ReorderThread.getUnsafe().storeFence();
y = a;
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("第" + i + "次(" + x + "," + y + ")");
if (x == 0 & y == 0) {
break;
}
}
}
/**
* 通过Unsafe 插入内存屏障
*
* @return
*/
public static Unsafe getUnsafe() {
try {
Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
theUnsafe.setAccessible(true);
return (Unsafe) theUnsafe.get(null);
} catch (Exception e) {
return null;
}
}
}
1.7、双重检验锁为什么需要加上volatile
public class LazyDoubleCheckSingleton {
public volatile static LazyDoubleCheckSingleton singleton = null;
private LazyDoubleCheckSingleton() {
}
public static LazyDoubleCheckSingleton getInstance() {
//先判断是否存在,不存在再加锁处理
if (singleton == null) {
//在同一个时刻加了锁的那部分程序只有一个线程可以进入
synchronized (LazyDoubleCheckSingleton.class) {
if (singleton == null) {
singleton = new LazyDoubleCheckSingleton();
//1、分配内存给这个对象
//2、初始化对象
//3、设置singleton指向刚分配的内存地址
//singleton利用volatile关键字防止指令重排序
}
}
}
return singleton;
}
}
注意:在声明public volatile static LazyDoubleCheckSingleton singleton = null;中 ,如果去掉volatile关键字,我们在new操作存在重排序的问题。
getInstance() 获取对象过程精简为3步如下
1.分配对象的内存空间
2.调用构造函数初始化
3.将对象复制给变量
如果没有volatile关键字修饰 singleton 变量,则有可能先执行将对象复制给变量,再执行调用构造函数初始化,导致另外一个线程获取到该对象不为空,但是该构造函数没有初始化的半初始化对象,会导致报错 。就是另外一个线程拿到的是一个不完整的对象。
二. synchronized的实现原理与应用
2.1. 概述
在jdk1.6之前,synchronized是基于底层操作系统的Mutex Lock实现的,每次获取和释放锁都会带来用户态和内核态的切换,从而增加系统的性能开销。在锁竞争激烈的情况下,synchronized同步锁的性能很糟糕。JDK 1.6,Java对synchronized同步锁做了充分的优化,甚至在某些场景下,它的性能已经超越了Lock同步锁
2.2. synchronized实现原理
synchronized 在 JVM 的实现原理是基于进入和退出管程(Monitor)对象来实现同步。但 synchronized 关键字实现同步代码块和同步方法的细节不一样,代码块同步是使用 monitorenter 和 monitorexit 指令实现的,方法同步通过调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的。
2.2.1 Java对象头
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。
· 实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
· 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
Java对象头是synchronized实现的关键,synchronized用的锁是存在Java对象头里的。
synchronized使用的锁对象是存储在Java对象头里的,jvm中采用2个字宽(一个字宽代表4个字节,一个字节8bit)来存储对象头(如果对象是数组则会分配3个字宽,多出来的1个字宽记录的是数组长度)。其主要结构是由 Mark Word 和 Class Metadata Address 组成。
其中 Mark Word 在默认情况下存储着对象的 HashCode、分代年龄、锁标记位等。Mark Word在不同的锁状态下存储的内容不同,在32位JVM中默认状态为下:
运行期间,Mark Word里存储的数据随锁标志位的变化而变化,可能存在如下4种数据。
2.2.2 synchronized同步的底层实现
上面说到 JVM 基于进入和退出 Monitor 对象来实现方法同步和代码块同步。
Monitor(监视器锁)本质是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的。Mutex Lock 的切换需要从用户态转换到核心态中,因此状态转换需要耗费很多的处理器时间。所以synchronized是Java语言中的一个重量级操作。
下面讲解 synchronized 同步代码块的过程。
public class SynTest{
public int i;
public void syncTask(){
synchronized (this){
i++;
}
}
}
反编译后结果如下:
D:\Desktop>javap SynTest.class
Compiled from "SynTest.java"public class SynTest {
public int i;
public SynTest();
public void syncTask();
}
D:\Desktop>javap -c SynTest.class
Compiled from "SynTest.java"public class SynTest {
public int i;
public SynTest();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public void syncTask();
Code:
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_0
5: dup
6: getfield #7 // Field i:I
9: iconst_1
10: iadd
11: putfield #7 // Field i:I
14: aload_1
15: monitorexit
16: goto 24
19: astore_2
20: aload_1
21: monitorexit
22: aload_2
23: athrow
24: return
Exception table:
from to target type
4 16 19 any
19 22 19 any
}
关注 monitorenter 和 monitorexit:
3: monitorenter
//省略
15: monitorexit
16: goto 24
//省略
21: monitorexit
从字节码中可知同步语句块的实现使用的是 monitorenter 和 monitorexit 指令
我们再来看看同步方法的过程:
public class SynTest{
public int i;
public synchronized void syncTask(){
i++;
}
}
反编译:javap -verbose -p SynTest
Classfile /D:/Desktop/SynTest.class
Last modified 2020年4月2日; size 278 bytes
SHA-256 checksum 0e7a02cd496bdaaa6865d5c7eb0b9f4bfc08a5922f13a585b5e1f91053bb6572
Compiled from "SynTest.java"
public class SynTest
minor version: 0
major version: 57
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #8 // SynTest
super_class: #2 // java/lang/Object
interfaces: 0, fields: 1, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Fieldref #8.#9 // SynTest.i:I
#8 = Class #10 // SynTest
#9 = NameAndType #11:#12 // i:I
#10 = Utf8 SynTest
#11 = Utf8 i
#12 = Utf8 I
#13 = Utf8 Code
#14 = Utf8 LineNumberTable
#15 = Utf8 syncTask
#16 = Utf8 SourceFile
#17 = Utf8 SynTest.java
{
public int i;
descriptor: I
flags: (0x0001) ACC_PUBLIC
public SynTest();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
public synchronized void syncTask();
descriptor: ()V
flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=3, locals=1, args_size=1
0: aload_0
1: dup
2: getfield #7 // Field i:I
5: iconst_1
6: iadd
7: putfield #7 // Field i:I
10: return
LineNumberTable:
line 5: 0
line 6: 10
}
SourceFile: "SynTest.java"
注意: flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有 monitor, 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放 monitor。
2.3. 同步过程(锁升级过程)
上面讲解了,synchronized 在开始的时候是依靠操作系统的互斥锁来实现的,是个重量级操作,为了减少获得锁和释放锁带来的性能消耗,在 JDK 1.6中,引入了偏向锁和轻量级锁。锁一共有4中状态:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几种状态会随着竞争情况逐渐升级,但不能降级,目的是为了提高锁和释放锁的效率。
2.3.1 偏向锁
大部分情况下,锁不存在多线程竞争,偏向锁就是为了在只有一个线程执行同步块时提高性能。
偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。
获得过程:
1. 访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为 01——确认为可偏向状态。
2. 如果为可偏向状态,则测试 Thread ID 是否指向当前线程,如果是,执行同步代码。
3. 如果不是指向当前线程,使用 CAS 竞争锁,如果竞争成功,则将 Mark Word 中 Thread ID 设置为当前线程ID,并在栈帧中锁记录(Lock Record)里存储当前线程ID。
4. 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint,在这个时间点上没有正在执行的字节码)时,会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着(因为可能持有偏向锁的线程已经执行完毕,但是该线程并不会主动去释放偏向锁)。
5. 如果线程不处于活动状态,则将对象头设置成无锁状态(标志位为“01”),然后重新偏向新的线程;如果线程仍然活着,撤销偏向锁后升级到轻量级锁状态(标志位为“00”),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁。
锁释放过程:
其实就是上面锁获得过程的四五步。
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。
偏向锁的撤销,需要等待全局安全点(这个时间点没有正在执行的字节码)。
1.到全局安全点后,先暂停拥有偏向锁的线程,检查该线程是否或者。
2.不活动或已经退出代码块,则对象头设置为无锁状态,然后重新偏向新的线程。
3.如果仍然活着,则遍历线程栈中所有的 Lock Record,如果能找到对应的 Lock Recor d 说明偏向的线程还在执行同步代码块中的代码。需要升级为轻量级锁,直接修改偏向线程栈中的Lock Record。
4.此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁。
在书中这一部分是这样说的:
当一个线程访问同步块并获得锁时,会在对象头和栈帧中锁记录存储锁偏向的线程ID,以后该线程在进入退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单测试一下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁。如果成功,表示线程已经获得了锁。如果失败,则需要再测试 Mark Word 中偏向锁的标识是否设置为 1(表示当前是偏向锁),如果没有设置,则使用 CAS 竞争锁,如果设置了,则尝试 CAS 将对象头的偏向锁指向当前线程。
个人觉得这一部分书中似乎稍微有点出入,我查看了很多博客,正常按逻辑分析的话,应该也是先判断锁标志位,判断出现在锁的状态,而不是先判断锁的线程ID是否指向自己。
2.3.2 轻量级锁
锁获得过程:
1. 如果锁对象不是偏向模式或已经偏向其他线程,这时候会构建一个无锁状态的mark word设置到Lock Record中去,我们称Lock Record中存储对象mark word的字段叫 Displaced Mark Word。
2. 拷贝对象头中的Mark Word复制到锁记录中。然后虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针。
3. 如果更新成功,当前线程获得锁,执行同步代码。如果更新失败,当前线程便尝试使用自旋来获取锁。
4. 当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞。
锁释放过程:
1. 通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word。
2. 如果替换成功,整个同步过程就完成了。
3.如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。
2.3.3 重量级锁
重量级锁的上锁过程参考上面步骤 4 ,轻量级锁膨胀为重量级锁,Mark Word的锁标记位更新为10,Mark Word 指向互斥量(重量级锁)。
Synchronized 的重量级锁是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本质又是依赖于底层的操作系统的 Mutex Lock(互斥锁)来实现的,文章开头有讲解。而操作系统实现线程之间的切换需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。
2.4. synchronized的三种应用方式
Java中每一个对象都可以作为锁,具体表现为如下三种形式:
1. 对于实例方法,也就是普通同步方法,锁是当前实例对象。
2. 对于静态同步方法,锁是当前类的 class 对象。
3. 对于同步方法块,锁是 synchronized 括号里配置的对象。
简单地说,synchronized修饰,表现为两种锁,一种是对调用该方法的对象加锁,俗称对象锁或实例锁,另一种是对该类对象加锁,俗称类锁。
2.4.1 对象锁
形象的理解:
Java 中每个对象都有一个锁,并且是唯一的。假设分配的一个对象空间,里面有多个方法,相当于空间里面有多个小房间,如果我们把所有的小房间都加锁,因为这个对象只有一把钥匙,因此同一时间只能有一个人打开一个小房间,然后用完了还回去,再由 JVM 去分配下一个获得钥匙的人。
这样的话,对于一些面试问题就好解决了。
1.同一个对象在两个线程中分别访问该对象的两个同步方法
2.不同对象在两个线程中调用同一个同步方法
第一个问题,因为锁针对的是对象,当对象调用一个synchronized方法时,其他同步方法需要等待其执行结束并释放锁后才能执行。
第二个问题,因为是两个对象,锁针对的是对象,并不是方法,所以可以并发执行,不会互斥。形象的来说就是因为我们每个线程在调用方法的时候都是new 一个对象,那么就会出现两个空间,两把钥匙。
2.4.2 类锁
类对象只有一个,可以理解为任何时候都只有一个空间,里面有N个房间,一把锁,一把钥匙。
问题:
1. 用类直接在两个线程中调用两个不同的同步方法
2. 用一个类的静态对象在两个线程中调用静态方法或非静态方法
3. 一个对象在两个线程中分别调用一个静态同步方法和一个非静态同步方法
因为对静态对象加锁实际上对类(.class)加锁,类对象只有一个,可以理解为任何时候都只有一个空间,里面有N个房间,一把锁,因此房间(同步方法)之间一定是互斥的。因为是一个对象调用,所以,1、2都会互斥。
第三个问题,因为虽然是一个对象调用,但是两个方法的锁类型不同,调用的静态方法实际上是类对象在调用,即这两个方法产生的并不是同一个对象锁,因此不会互斥,会并发执行。
2.5. synchronized 其他锁优化
2.5.1 锁消除
锁消除即删除不必要的加锁操作。虚拟机即时编辑器在运行时,对一些“代码上要求同步,但是被检测到不可能存在共享数据竞争”的锁进行消除。
2.5.2 锁粗化
如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。
如果虚拟机检测到有一串零碎的操作都是对同一对象的加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。
2.5.3 自旋锁与自适应自旋锁
引入自旋锁的原因:
互斥同步对性能最大的影响是阻塞的实现,因为挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来很大的压力。同时虚拟机的开发团队也注意到在许多应用上面,共享数据的锁定状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。
自旋锁:
让该线程执行一段无意义的忙循环(自旋)等待一段时间,不会被立即挂起(自旋不放弃处理器额执行时间),看持有锁的线程是否会很快释放锁。自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用 -XX:+UseSpinning 开启;在JDK1.6中默认开启。
自旋锁的缺点:
自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好;反之,自旋的线程就会白白消耗掉处理器的资源,它不会做任何有意义的工作,这样反而会带来性能上的浪费。
自适应的自旋锁:
JDK1.6 引入自适应的自旋锁,自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:如果在同一个锁的对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。简单来说,就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。
2.6. synchronized 的可重入性
从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功,在 java 中 synchronized 是基于原子性的内部锁机制,是可重入的,因此在一个线程调用 synchronized 方法的同时在其方法体内部调用该对象另一个 synchronized 方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是 synchronized 的可重入性。
7.synchronized总结
synchronized特点: 保证内存可见性、操作原子性。在经过jdk6的优化,synchronized 的性能其实不必 JVM 实现的 Reentrantlock 差,甚至有的时候比它更优秀,这也是 Java concurrent 包下很多类的原理都是基于 synchronized 实现的原因。
三.原子操作的实现原理
原子(atomic)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为“不可被中断的一个或一系列操作”。在多处理器上实现原子操作就变得有点复杂。让我们一起来聊一聊在Intel处理器和Java里是如何实现原子操作的。
3.1 术语定义
在了解原子操作的实现原理前,先要了解一下相关的术语,如表2-7所示。
表2-7 CPU术语定义
3.2 处理器如何实现原子操作
32位IA-32处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。Pentium 6和最新的处理器能自动保证单处理器对同一个缓存行里进行16/32/64位的操作是原子的,但是复杂的内存操作处理器是不能自动保证其原子性的,比如跨总线宽度、
跨多个缓存行和跨页表的访问。但是,处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。
(1) 使用总线锁保证原子
第一个机制是通过总线锁保证原子性。如果多个处理器同时对共享变量进行读改写操作
(i++就是经典的读改写操作),那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一致。举个例子,如果i=1,我们进行两次i++操作,我们期望的结果是3,但是有可能结果是2,如图2-3所示。
图2-3 结果对比
原因可能是多个处理器同时从各自的缓存中读取变量i,分别进行加1操作,然后分别写入系统内存中。那么,想要保证读改写共享变量的操作是原子的,就必须保证CPU1读改写共享变量的时候,CPU2不能操作缓存了该共享变量内存地址的缓存。
处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。
(2) 使用缓存锁保证原子性
第二个机制是通过缓存锁定来保证原子性。在同一时刻,我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。
频繁使用的内存会缓存在处理器的L1、L2和L3高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁,在Pentium 6和目前的处理器中可以使用“缓存锁定”的方式来实现复杂的原子性。所谓“缓存锁定”是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子 性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效,在如图2-3所示的例子中,当CPU1修改缓存行中的i时使用了缓存锁定,那么CPU2就不能同时缓存i的缓存行。
但是有两种情况下处理器不会使用缓存锁定。
第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行
(cache line)时,则处理器会调用总线锁定。
第二种情况是:有些处理器不支持缓存锁定。对于Intel 486和Pentium处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。
针对以上两个机制,我们通过Intel处理器提供了很多Lock前缀的指令来实现。例如,位测试和修改指令:BTS、BTR、BTC;交换指令XADD、CMPXCHG,以及其他一些操作数和逻辑指令(如ADD、OR)等,被这些指令操作的内存区域就会加锁,导致其他处理器不能同时访问它。
3.3 Java如何实现原子操作
在Java中可以通过锁和循环CAS的方式来实现原子操作。
3.3.1 使用循环CAS实现原子操作
JVM中的CAS操作正是利用了处理器提供的CMPXCHG指令实现的。自旋CAS实现的基本思路就是循环进行CAS操作直到成功为止,以下代码实现了一个基于CAS线程安全的计数器方法safeCount和一个非线程安全的计数器count。
private AtomicInteger atomicI = new AtomicInteger(0);
private int i = 0;
public static void main(String[] args) {
final Counter cas = new Counter();
List<Thread> ts = new ArrayList<Thread>(600);
long start = System.currentTimeMillis();
for (int j = 0; j < 100; j++) {
Thread t = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
cas.count();
cas.safeCount();
}
}
});
ts.add(t);
}
for (Thread t : ts) {
t.start();
}
// 等待所有线程执行完成
for (Thread t : ts) {
try {
t.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(cas.i);
System.out.println(cas.atomicI.get());
System.out.println(System.currentTimeMillis() - start);
}
/**
* 使用CAS实现线程安全计数器
*/
private void safeCount() {
for (; ; ) {
int i = atomicI.get();
boolean suc = atomicI.compareAndSet(i, ++i);
if (suc) {
break;
}
}
}
/**
* 非线程安全计数器
*/
private void count() {
i++;
}
}
从Java 1.5开始,JDK的并发包里提供了一些类来支持原子操作,如AtomicBoolean(用原子方式更新的boolean值)、AtomicInteger(用原子方式更新的int值)和AtomicLong(用原子方式更新的long值)。这些原子包装类还提供了有用的工具方法,比如以原子的方式将当前值自增1和自减1。
3.3.2 CAS实现原子操作的三大问题
在Java并发包中有一些并发框架也使用了自旋CAS的方式来实现原子操作,比如LinkedTransferQueue类的Xfer方法。CAS虽然很高效地解决了原子操作,但是CAS仍然存在三大问题。ABA问题,循环时间长开销大,以及只能保证一个共享变量的原子操作。
1) ABA问题。因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加1,那么A→B→A就会变成1A→2B→3A。从Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
public boolean compareAndSet(
V expectedReference, // 预期引用
V newReference, // 更新后的引用
int expectedStamp, // 预期标志
int newStamp // 更新后的标志
)
2) 循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。pause指令有两个作用:第一,它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;第二,它可以避免在退出循环的时候因内存顺序冲突(Memory Order Violation)而引起CPU流水线被清空(CPU Pipeline Flush),从而提高CPU的执行效率。
3) 只能保证一个共享变量的原子操作。当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij。从Java 1.5开始, JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作。
3.3.3 使用锁机制实现原子操作
锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。有意思的是除了偏向锁,JVM实现锁的方式都用了循环CAS,即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁。