Java并发机制的底层实现
文章目录
Java内存模型
Java内存模型试图屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java程序在各种平台下都能达到一致的内存访问效果。
线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
主内存与工作内存
在计算机中,在读取速度上:硬盘 < 内存 < CPU,为解决CPU与内存速度相差过大的问题,在CPU和内存之间加入缓存cache。
处理器上的寄存器的读写的速度比内存快几个数量级,为了解决这种速度矛盾,在它们之间加入了高速缓存。
线程对变量的操作(读取赋值等)必须在工作内存中进行,首先概要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量。
加入高速缓存带来了一个新的问题:缓存一致性(MESI)。如果多个缓存共享同一块主内存区域,那么多个缓存的数据可能会不一致,需要一些协议(Protocol)来解决这个问题。如果不行,需要加LOCK#锁锁总线。
所有的变量都存储在主内存中,每个线程还有自己的工作内存,工作内存存储在高速缓存或者寄存器中,保存了该线程使用的变量的主内存副本拷贝。
线程只能直接操作工作内存中的变量,不同线程之间的变量值传递需要通过主内存来完成。
内存间交互操作
Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互操作。
- read:把一个变量的值从主内存传输到工作内存中
- load:在 read 之后执行,把 read 得到的值放入工作内存的变量副本中
- use:把工作内存中一个变量的值传递给执行引擎
- assign:把一个从执行引擎接收到的值赋给工作内存的变量
- store:把工作内存的一个变量的值传送到主内存中
- write:在 store 之后执行,把 store 得到的值放入主内存的变量中
- lock:作用于主内存的变量
- unlock:作用于主内存的变量
超线程
超线程(HT, Hyper-Threading)是英特尔研发的一种技术。
一个线程在执行时会占用CPU资源,其他线程想要得到执行就必须等待该线程将CPU资源让出。
利用超线程技术,模拟出的两个逻辑内核共享同一个CPU资源,所以同一时刻可以有两个线程都占用CPU资源,因此这两个线程都可以得到执行,这就是实现同一时间执行两个线程的并行操作。一个ALU(算术逻辑单元)对应多个PC与Register(寄存器),所谓的四核八线程就是超线程实现的。
cache line
CPU在读内存时一次读一个块(cache line 缓存行),64字节,因为读取一个数据后往往会读它相邻的数据。
缓存行对齐写法:
public class CacheLinePadding {
private static class Padding {
// 占据7 * 8个字节,保证了arr[0].x,arr[1].x在不同的cache line,避免相互干扰,加快运行速度
public volatile long p1, p2, p3, p4, p5, p6, p7;
}
private static class T extends Padding {
private volatile long x = 0L;
}
public static T[] arr = new T[2];
static {
arr[0] = new T();
arr[1] = new T();
}
}
并发框架Disruptor就是采用这种写法。
缓存一致性MESI可参考:https://blog.csdn.net/zlt995768025/article/details/81275373
它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
对象的内存布局
-
Mark Word:包含一系列的标记位,比如轻量级锁的标记位,偏向锁标记位等等。在32位系统占4字节,在64位系统中占8字节;
-
Class Pointer:用来指向对象对应的Class对象(其对应的元数据对象)的内存地址。在32位系统占4字节,在64位系统中占8字节;
-
Length:如果是数组对象,还有一个保存数组长度的空间,占4个字节;
-
对象实际数据包括了对象的所有成员变量,其大小由各个成员变量的大小决定;
-
对齐填充:Java对象占用空间是8字节对齐的,即所有Java对象占用bytes数必须是8的倍数。例如,一个包含两个属性的对象:int和byte,这个对象需要占用8+4+1=13个字节,这时就需要加上大小为3字节的padding进行8字节对齐,最终占用大小为16个字节。
JVM启动参数-XX:+UseCompressedClassPointer
会把ClassPointer64位指针的8字节压缩为4字节。-XX:+UseCompressedOops
会把指向对象的64位指针的8字节压缩为4字节。因此对象头一共12个字节。
原因可参考:https://blog.csdn.net/zjerryj/article/details/77206928
使用工具JOL(Java Object Layout)查看
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
查看Object对象的内存布局:
import org.openjdk.jol.info.ClassLayout;
public class JolDemo {
public static void main(String[] args) {
Object o = new Object();
// 输出内存布局
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
输出结果:
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
因此一个Object对象占16字节。
当给一个对象加锁时:
public class JolDemo {
public static void main(String[] args) {
Object o = new Object();
//System.out.println(ClassLayout.parseInstance(o).toPrintable());
synchronized (o) {
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
}
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) e8 f0 67 02 (11101000 11110000 01100111 00000010) (40366312)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
可见MarkWord发生了变化
happens-before
JSR-133使用happens-before的概念来阐述操作之间的内存可见性,让线程遵守happens-before原则来解决多线程的可见性。如果一个操作 happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见
注:两个操作之间有happens-before关系,并不意味着前一个操作必须在后一个操作之前执行,仅仅要求前一个操作对后一个操作可见。
happens-before规则:
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作,编译器不会对存在数据依赖关系的操作重排序(as-if-serial语义)。
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
- start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
- join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
sychronized实现原理
锁升级的过程
对于同一问题的处理,并不一定是创建的线程数量越多,执行越快,这是由于线程有创建和上下文切换的开销。而锁的上下文消耗尤为严重,因此JDK6对锁进行了优化。
这里的锁优化主要是指JVM对synchronized的优化。
JDK 1.6引入了偏向锁和轻量级锁,从而让锁拥有了四个状态:无锁状态(unlocked)、偏向锁状态(biasble)、轻量级锁状态(lightweight locked)和重量级锁状态(inflated)。这几个状态会随着竞争逐渐升级,锁可以升级但不能降级。
锁升级对应着对象头的变化:
偏向锁
HotSpot的作者发现:大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
偏向锁的思想是偏向于让第一个获取锁对象的线程,这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS 操作也不再需要。
当锁对象第一次被线程获得的时候,进入偏向状态,标记为 1 01。同时使用 CAS 操作将线程 ID 记录到 Mark Word 中,如果 CAS 操作成功,这个线程以后每次进入这个锁相关的同步块就不需要再进行任何同步操作。
当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定状态或者轻量级锁状态。
轻量级锁
轻量级锁又称为自旋锁、无锁、自适应自旋,运行在用户态。
下图左侧是一个线程的虚拟机栈,其中有一部分称为Lock Record的区域,这是在轻量级锁运行过程创建的,用于存放锁对象的Mark Word。而右侧就是一个锁对象,包含了Mark Word和其它信息。
轻量级锁是相对于传统的重量级锁而言,它使用CAS操作来避免重量级锁使用互斥量的开销。对于绝大部分的锁,在整个同步周期内都是不存在竞争的,因此也就不需要都使用互斥量进行同步,可以先采用CAS操作进行同步,如果CAS失败了再改用互斥量进行同步。
当尝试获取一个锁对象时,如果锁对象标记为0 01,说明锁对象的锁未锁定(unlocked)状态。此时虚拟机在当前线程的虚拟机栈中创建Lock Record,然后使用 CAS 操作将对象的Mark Word更新为Lock Record 指针。如果 CAS 操作成功了,那么线程就获取了该对象上的锁,并且对象的Mark Word的锁标记变为 00,表示该对象处于轻量级锁状态。
如果 CAS 操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的虚拟机栈,如果是的话说明当前线程已经拥有了这个锁对象,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程线程抢占。
**竞争加剧:**有线程超过十次自旋,-XX:PreBlockSpin
,或者自旋线程数超过CPU核数的一半;1.6以后,JVM加入了自适应自旋Adapative Self Spinning,由JVM自己控制。
当竞争加剧膨胀为重量级锁。
重量级锁
重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。
**每一个重量级锁(内核态)都维护着一个队列,没有轮到执行的线程不消耗任何CPU,处于阻塞状态。**但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。
在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的),ObjectMonitor中有几个关键属性:
- _owner:指向持有ObjectMonitor对象的线程;
- _cxq(竞争队列):是一个单向链表,被挂起线程等待重新竞争锁的链表;
- _WaitSet:存放处于wait状态的线程队列;
- _EntryList(锁候选者列表):存放处于等待锁block状态的线程队列,EntryList是一个双向链表。当EntryList为空,cxq不为空,Owener会在unlock时,将cxq中的数据移动到EntryList。并指定EntryList列表头的第一个线程为OnDeck线程。在cxq中的队列可以继续自旋等待锁,若达到自旋的阈值仍未获取到锁则会调用park方法挂起。而EntryList中的线程都是被挂起的线程。
- _recursions:锁的重入次数;
- _count:用来记录该线程获取锁的次数;
当一个线程参与锁的竞争时,通过CAS尝试把monitor的_owner
字段设置为当前线程(底层依然是lock comxchg
),失败后通过自旋执行ObjectMonitor::EnterI
方法等待锁的释放。
cur = Atomic::cmpxchg_ptr (Self, &_owner, NULL) ;
当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个ObjectWaiter
对象插入到cxq的队列的队首,自旋一段时间然后调用park
函数挂起当前线程。JDK的ReentrantLock
底层也是用该方法挂起线程的。
当多个线程同时访问一段同步代码时,首先会进入**_cxq与_EntryList队列中,当某个线程获取到对象的monitor后进入_Owner区域并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器count加1,即获得对象锁。若持有monitor的线程调用wait()方法,将释放当前持有的monitor,_owner变量恢复为null,_count自减1,同时该线程进入_WaitSet**集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。
锁的释放:
根据不同的策略(由QMode指定),从cxq或EntryList中获取头节点,通过ObjectMonitor::ExitEpilog
方法唤醒该节点封装的线程,唤醒操作最终由unpark
完成。
获取monitor总结
- 线程首先通过CAS尝试将monitor的owner设置为自己;
- 若执行成功,则判断该线程是不是重入。若是重入,则执行recursions + 1,否则执行recursions = 1;
- 若失败,则将自己封装为ObjectWaiter,并通过CAS加入到cxq中。
释放monitor总结
- 判断是否为重量级锁,是则继续流程;
- recursions - 1;
- 根据不同的策略设置一个OnDeckThread。
参考:
cxq与EntryList
public class FairDemo {
public static void main(String[] args) throws InterruptedException {
System.out.println("----测试开始----");
FairDemo fd = new FairDemo();
for (int i = 1; i <= 10; i++) {
new Thread(() -> {
fd.fair();
}, "Thread " + i).start();
}
}
ReentrantLock lock = new ReentrantLock();
public synchronized void fair () {
System.out.println(Thread.currentThread().getName() + "开始运行");
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
输出结果:
----测试开始----
Thread 1开始运行
Thread 10开始运行
Thread 9开始运行
Thread 8开始运行
Thread 7开始运行
Thread 6开始运行
Thread 5开始运行
Thread 4开始运行
Thread 3开始运行
Thread 2开始运行
可以看出后来的线程反而先获得了锁,即默认策略下(QMode=0),在线程1释放锁后一定是线程10先获得锁。因为在获取锁时,是将当前线程插入到cxq的头部,而释放锁时,默认策略是:如果EntryList为空,则将cxq中的元素按原有顺序插入到到EntryList,并唤醒第一个线程。也就是当EntryList为空时,是后来的线程先获取锁。这点JDK中的Lock机制是不一样的。
偏向锁、轻量级锁的状态转换
自旋锁
互斥同步进入阻塞状态的开销都很大,应该尽量避免。在许多应用中,共享数据的锁定状态只会持续很短的一段时间。自旋锁的思想是让一个线程在请求一个共享数据的锁时执行忙循环(自旋)一段时间,如果在这段时间内能获得锁,就可以避免进入阻塞状态。
自旋锁虽然能避免进入阻塞状态从而减少开销,但是它需要进行忙循环操作占用 CPU 时间,它只适用于共享数据的锁定状态很短的场景。
在JDK1.6中引入了自适应的自旋锁。自适应意味着自旋的次数不再固定了,而是由前一次在同一个锁上的自旋次数及锁的拥有者的状态来决定。
锁消除
锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除。
锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除。
对于一些看起来没有加锁的代码,其实隐式的加了很多锁。例如下面的字符串拼接代码就隐式加了锁:
public static String concatString(String s1, String s2, String s3) {
return s1 + s2 + s3;
}
String 是一个不可变的类,编译器会对 String 的拼接自动优化。在 JDK 1.5 之前,会转化为 StringBuffer 对象的连续 append() 操作:
public static String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
每个 append() 方法中都有一个同步块。虚拟机观察变量 sb,很快就会发现它的动态作用域被限制在 concatString()
方法内部。也就是说,sb 的所有引用永远不会逃逸到 concatString() 方法之外,其他线程无法访问到它,因此可以进行消除。
锁粗化
如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗。
上一节的示例代码中连续的 append() 方法就属于这类情况。如果虚拟机探测到由这样的一串零碎的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部。对于上一节的示例代码就是扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,这样只需要加锁一次就可以了。
总结Synchronized的执行过程:
- 检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁 。
- 如果不是,CAS用当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1 。
- 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
- 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁 。
- 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
- 如果自旋成功则依然处于轻量级状态。
- 如果自旋一定时间后未成功,则升级为重量级锁。
[参考资料] https://www.jianshu.com/p/2bd4a3c8c30c
Sychronized的底层实现
sychronized的实现过程:
- Java代码:sychronized
- 字节码层面:monitorenter + monitorexit
- 执行过程中自动升级
- 汇编:lock comxchg指令
- 操作系统:Mutex Lock
Synchronized与Lock的对比
原始构成:
-
synchronized是关键字,属于JVM层面;
sychronized底层是通过monitor对象来完成,wait/notify等方法也依赖于monitor对象,只有在同步或方法中才能调用wait/notify等方法。monitorenter ----> monitorexit
-
Lock是具体类,是API层面的锁。
使用方法:
- sychronized不需要用户取手动释放锁,当synchronized代码执行完后系统会自动让线程释放对锁的占用;
- ReentrantLock则需要用户去手动释放锁,若没有主动释放锁,就有可能导致出现死锁现象,需要
lock()
和unlock()
方法配合try/finally语句块来完成。
等待是否可中断:
- synchronized不可中断,除非抛出异常或者正常运行完成;
- ReentrantLock可中断,设置超时方法
tryLock(long timeout, TimeUnit unit)
,或者lockInterruptibly()
放代码块中,调用interrupt()
方法可中断。
加锁是否公平:
- synchronized非公平锁;
- ReentrantLock两者都可以,默认公平锁,构造方法可以传入boolean值,true为公平锁,false为非公平锁。
锁绑定多个条件Condition:
- synchronized无法绑定多个条件;
- ReentrantLock用来实现分组唤醒线程,可以精确唤醒,而不是像synchronized要么随机唤醒一个线程(notify)要么唤醒全部线程(notifyAll)。
执行顺序:
- Synchronized在特定的情况下对于已经在等待的线程是后来的线程先获得锁(上文有说),而ReentrantLock对于已经在等待的线程一定是先来的线程先获得锁;
volatile关键字
volatile是多线程的一种轻量级的同步机制,保证可见性,禁止指令重排,但不保证原子性。
volatile的特性
可见性
volatile保证了线程之间的可见性,也就是对一个volatile变量的读,总是能看到任意线程对这个volatile变量最后的写入。
import java.util.concurrent.TimeUnit;
public class VolatileDemo {
public static void main(String[] args) {
// 验证volatile的可见性
visibilityByVolatile();
}
/**
* volatile可以保证可见性,及时通知其他线程,主物理内存的值已经被修改
*/
public static void visibilityByVolatile() {
MyData myData = new MyData();
//第一个线程
new Thread(() -> {
System.out.println(Thread.currentThread().getName() + "\t come in");
try {
TimeUnit.SECONDS.sleep(3);
myData.addToSixty();
System.out.println(Thread.currentThread().getName() + "\t update value:" + myData.num);
} catch (Exception e) {
e.printStackTrace();
}
}, "thread1").start();
//第二个线程是main线程
while (myData.num == 0) {
//如果myData的num一直为零,main线程一直在这里循环
}
System.out.println(Thread.currentThread().getName() + "\t mission is over, num value is " + myData.num);
}
}
class MyData {
// int num = 0; 不加volatile主线程进入死循环
volatile int num = 0;
public void addToSixty() {
this.num = 60;
}
}
p.s. 对于实现了缓存一致性协议的CPU,MESI其实已经保证了多核内存间的可见性。这时候volatile是不是就没用了呢?答案肯定是volatile依然有用:
-
第一:代码运行的cpu是不是实现了MESI协议是不一定的;
-
第二,就算在实现了MESI的CPU上,volatile一样不可或缺。除了禁止指令重排序的作用外,由于MESI只是保证了L1-3cache之间的可见性,但是CPU和L1之间还有像storebuffer之类的缓存,而volatile规范保证了对它修饰的变量的写指令会使得当前cpu所有缓存写到被mesi保证可见性的L1-3cache中。
不保证原子性
原子性:不可分割,即某个线程正在做某个具体业务时,中间不可以被加塞或者被分割,需要整体完整,要么同时成功,要么同时失败。
对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
例如多线程num++
1000次,最后num
会小于1000,出现写覆盖。
因为实际上一个++
操作在字节码中分为三步,不是一个原子操作:
getfield #2
iconst_1
iadd
putfield #2
禁止指令重排
volatile还可以禁止代码重排,对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
在计算机执行程序时,为了提高性能,编译器和处理器常常会对指令做重拍,一般分以下三种:
源代码 --> id1["编译器优化的重排"]
id1 --> id2[指令并行的重排]
id2 --> id3[内存系统的重排]
id3 --> 最终执行的指令
单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致,处理器在进行重排顺序是必须要考虑指令之间的数据依赖性,多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性时无法确定的,结果无法预测。
例如:
public void mySort() {
int x = 11; // 语句1
int y = 12; // 语句2
x = x + 5; // 语句3
y = x * x; //语句4
}
在保证数据依赖性的前提下可能的执行顺序为:1234、2134、1324
代码重排案例一:
声明变量:int a,b,x,y=0
线程1 | 线程2 |
---|---|
x = a; | y = b; |
b = 1; | a = 2; |
结果 | x = 0 y=0 |
如果编译器对这段程序代码执行重排优化后,可能出现如下情况:
线程1 | 线程2 |
---|---|
b = 1; | a = 2; |
x = a; | y = b; |
结果 | x = 2 y=1 |
这个结果说明在多线程环境下,由于编译器优化重排的存在,两个线程中使用的变量依赖性是无法确定的。
代码重排案例二:
public class ReSortSeqDemo {
int a = 0;
boolean flag = false;
public void method1() {
a = 1; // 语句1
flag = true; // 语句2
}
public void method1() {
if (flag) {
a = a + 5; //语句1如果与语句2重排,结果完全不同
}
}
}
volatile的内存语义
- 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存;
- 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量。
volatile内存语义的实现
内存屏障
内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,他的作用有两个:
- 保证特定操作的执行顺序
- 保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)
由于编译器和处理器都能执行指令重排优化,如果在这之前插入一条内存屏障则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排顺序,也就是说通过插入内存屏障禁止在内存屏障前后的指令重排序。内存屏障另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。
- 对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据;
- 对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。
为了实现volatile内存语义,JMM会分别限制编译器重排序和处理器重排序。
编译器重排序
- 当第二个操作为volatile写操作时,不管第一个操作是什么(普通读写或者volatile读写),都不能进行重排序。这个规则确保volatile写之前的所有操作都不会被重排序到volatile写之后;
- 当第一个操作为volatile读操作时,不管第二个操作是什么,都不能进行重排序。这个规则确保volatile读之后的所有操作都不会被重排序到volatile读之前;
- 当第一个操作是volatile写操作时,第二个操作是volatile读操作,不能进行重排序。
处理器重排序
编译器在生成字节码时,会在指令中插入内存屏障来禁止特定类型的处理器重排序,volatile的内存屏障策略非常严格保守:
- 在每个volatile写操作的前面插入一个StoreStore屏障
- 在每个volatile写操作的后面插入一个StoreLoad屏障
- 在每个volatile读操作的后面插入一个LoadLoad屏障
- 在每个volatile读操作的后面插入一个LoadStore屏障
底层原理
- Java代码中的volatile;
- 字节码中的标记:ACC_VOLATILE;
- JVM按照JSR(Java规范)加入内存屏障;
- hotspot实现:不是加内存屏障,而是直接用lock锁总线(保证可移植性)。
lock
指令的作用:
-
锁总线,其它CPU对内存的读写请求都会被阻塞,直到锁释放(效率太低),不过实际后来的处理器都采用锁缓存行替代锁总线,因为锁总线的开销比较大,锁总线期间其他CPU没法访问内存。
-
lock后的写操作会回写已修改的数据,同时让其它CPU相关缓存行失效,从而重新从主存中加载最新的数据,MESI缓存一致性协议+CPU总线嗅探机制(监听)
-
不是内存屏障却能完成类似内存屏障的功能,阻止屏障两边的指令重排序
当写两条线程Thread-A与Threab-B同时操作主存中的一个volatile变量i时,Thread-A写了变量i,那么:
- Thread-A发出LOCK#指令;
- 发出的LOCK#指令锁总线(或锁缓存行),同时让Thread-B高速缓存中的缓存行内容失效;
- Thread-A向主存回写最新修改的i;
Thread-B读取变量i,那么:
- Thread-B发现对应地址的缓存行被锁了,等待锁的释放,缓存一致性协议会保证它读取到最新的值。
详细可参考:https://blog.csdn.net/qq_26222859/article/details/52235930
DCL单例模式
Double Check Lock双端检索机制:
public class Singleton {
private volatile static Singleton uniqueInstance;
private Singleton() {
}
public static Singleton getUniqueInstance() {
if (uniqueInstance == null) {
synchronized (Singleton.class) {
if (uniqueInstance == null) {
uniqueInstance = new Singleton();
}
}
}
return uniqueInstance;
}
}
uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton();
这段代码其实是分为三步执行:
- 为对象分配内存空间allocate()
- 初始化 uniqueInstance
- 将 uniqueInstance 指向分配的内存地址
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1>3>2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化(半初始化)。
使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。
synchronized和volatile的区别
synchronized关键字和volatile关键字比较:
- volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。但是volatile关键字只能用于变量而synchronized关键字可以修饰方法以及代码块。synchronized关键字在JavaSE1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁以及其它各种优化之后执行效率有了显著提升,实际开发中使用synchronized关键字的场景还是更多一些。
- 多线程访问volatile关键字不会发生阻塞,而synchronized关键字可能会发生阻塞
- volatile关键字能保证数据的可见性,但不能保证数据的原子性。synchronized关键字两者都能保证。
- volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized关键字解决的是多个线程之间访问资源的同步性。
CAS
CompareAndSwap
CAS是英文单词CompareAndSwap的缩写,中文意思是:比较并替换。它是一条CPU并发原语。CAS需要有3个操作数:内存地址V,旧的预期值A,即将要更新的目标值B。
CAS指令执行时,当且仅当内存地址V当中的值与预期值A相等时,将内存地址V的值修改为B,否则就什么都不做。整个比较并替换的操作是一个原子操作(依靠UnSafe类),此操作具有volative读和写的内存操作。
Java并发包原子操作类(Atomic开头)就是采用CAS机制。
atomicInteger.compareAndSet(5, 2019)
从思想上来说,synchronized属于悲观锁,悲观的认为程序中的并发情况严重,所以严防死守,CAS属于乐观锁,乐观地认为程序中的并发情况不那么严重,所以让线程不断去重试更新。
在Java中除了上面提到的Atomic系列类,以及Lock系列类夺得底层实现,甚至在JAVA1.6以上版本,Synchronized转变为重量级锁之前,也会采用CAS机制。
CAS底层原理
CAS调用的是sun.misc.Unsafe类(rt.jar包)中的方法,为native方法。
对于atomicInteger.getAndIncrement()
方法,底层为:
public final int getAndIncrement() {
/*
this 表示当前对象
valueoffset(Long) 表示内存偏移量
1 表示每次增加1
*/
return unsafe.getAndAddInt(this, valueOffset, 1);
}
获取valueOffset
的方法:
CAS并发原语体现在JAVA语言中就是sun.misc.Unsafe类中各个方法。调用Unsafe类中的CAS方法,JVM会帮我们实现CAS汇编指令。这是一种完全依赖于硬件的功能,通过它实现了原子操作。由于CAS是一种系统原语,原语属于操作系统用语范畴,是由若干条指令组成的,用于完成某个功能的一个过程,并且原语的执行必须是连续的,在执行过程中不允许被中断,也就是说CAS是一条CPU的原子指令,不会造成数据不一致问题。
UnSafe类中的getAndAddInt
方法:
其中this.compareAndSwapInt
为一个native方法,具有原子性,
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
UnsafeWrapper("Unsafe_CompareAndSwapInt");
oop p = JNIHandles::resolve(obj);
jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END
//通过Atomic::cmpxchg实现比较替换,其中参数x是即将更新的值,参数e是原内存的值
核心方法是compxchg,这个方法所属的类文件是在OS_CPU目录下面,由此可以看出这个类是和CPU操作有关,进入代码如下(汇编指令):
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest,jint compare_value) {
// alternative for InterlockedCompareExchange
int mp = os::is_MP();
__asm {
mov edx, dest
mov ecx, exchange_value
mov eax, compare_value
LOCK_IF_MP(mp)
cmpxchg dword ptr [edx], ecx
}
}
这个方法里面都是汇编指令,可以看到LOCK_IF_MP(如果多个CPU则加锁)也有锁指令实现的原子操作,其实CAS也算是有锁操作,只不过是由CPU来触发,比synchronized性能好的多。
核心:lock cmpxchg
指令
在硬件上:lock
指令在执行后面指令的时候锁定一个北桥信号,不采用锁总线的方式。
[参考资料] https://blog.csdn.net/v123411739/article/details/79561458
CAS的缺点
CAS虽然很高效的解决了原子操作问题,但是CAS仍然存在三大问题。
-
循环时间长开销很大:
我们可以看到getAndAddInt方法执行时,如果CAS失败,会一直进行尝试。如果CAS长时间一直不成功,可能会给CPU带来很大的开销。 -
只能保证一个共享变量的原子操作:
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁来保证原子性。 -
ABA问题:
如果内存地址V初次读取的值是A,并且在准备赋值的时候检查到它的值仍然为A,那我们就能说它的值没有被其他线程改变过了吗?
如果在这段期间它的值曾经被改成了B,后来又被改回为A,那CAS操作就会误认为它从来没有被改变过。这个漏洞称为CAS操作的ABA问题Java并发包为了解决这个问题,提供了一个带有标记的原子引用类AtomicStampedReference
,它可以通过控制变量值的版本来保证CAS的正确性。因此,在使用CAS前要考虑清楚ABA问题是否会影响程序并发的正确性,如果需要解决ABA问题,改用传统的互斥同步可能会比原子类更高效。
ABA问题解决
原子引用类AtomicReference
import java.util.concurrent.atomic.AtomicReference;
public class AtomicRefrenceDemo {
public static void main(String[] args) {
User z3 = new User("张三", 22);
User l4 = new User("李四", 23);
AtomicReference<User> atomicReference = new AtomicReference<>();
atomicReference.set(z3);
System.out.println(atomicReference.compareAndSet(z3, l4) + "\t" + atomicReference.get().toString()); // true User(userName=李四, age=23)
System.out.println(atomicReference.compareAndSet(z3, l4) + "\t" + atomicReference.get().toString()); // false User(userName=李四, age=23)
}
}
@Getter
@ToString
@AllArgsConstructor
class User {
String userName;
int age;
}
AtomicStampedReference:时间戳版本号解决ABA问题
AtomicStampedReference它内部不仅维护了对象值,还维护了一个时间戳(我这里把它称为时间戳,实际上它可以使任何一个整数,它使用整数来表示状态值)。当AtomicStampedReference对应的数值被修改时,除了更新数据本身外,还必须要更新时间戳。当AtomicStampedReference设置对象值时,对象值以及时间戳都必须满足期望值,写入才会成功。因此,即使对象值被反复读写,写回原值,只要时间戳发生变化,就能防止不恰当的写入。
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.atomic.AtomicStampedReference;
/**
* ABA问题解决
* AtomicStampedReference
*/
public class ABADemo {
static AtomicReference<Integer> atomicReference = new AtomicReference<>(100);
static AtomicStampedReference<Integer> atomicStampedReference = new AtomicStampedReference<>(100, 1);
public static void main(String[] args) {
System.out.println("=====以下时ABA问题的产生=====");
new Thread(() -> {
atomicReference.compareAndSet(100, 101);
atomicReference.compareAndSet(101, 100);
}, "Thread 1").start();
new Thread(() -> {
try {
//保证线程1完成一次ABA操作
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(atomicReference.compareAndSet(100, 2019) + "\t" + atomicReference.get());
}, "Thread 2").start();
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("=====以下时ABA问题的解决=====");
new Thread(() -> {
int stamp = atomicStampedReference.getStamp();
System.out.println(Thread.currentThread().getName() + "\t第1次版本号" + stamp);
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
atomicStampedReference.compareAndSet(100, 101, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1);
System.out.println(Thread.currentThread().getName() + "\t第2次版本号" + atomicStampedReference.getStamp());
atomicStampedReference.compareAndSet(101, 100, atomicStampedReference.getStamp(), atomicStampedReference.getStamp() + 1);
System.out.println(Thread.currentThread().getName() + "\t第3次版本号" + atomicStampedReference.getStamp());
}, "Thread 3").start();
new Thread(() -> {
int stamp = atomicStampedReference.getStamp();
System.out.println(Thread.currentThread().getName() + "\t第1次版本号" + stamp);
try {
TimeUnit.SECONDS.sleep(4);
} catch (InterruptedException e) {
e.printStackTrace();
}
boolean result = atomicStampedReference.compareAndSet(100, 2019, stamp, stamp + 1);
System.out.println(Thread.currentThread().getName() + "\t修改是否成功" + result + "\t当前最新实际版本号:" + atomicStampedReference.getStamp());
System.out.println(Thread.currentThread().getName() + "\t当前最新实际值:" + atomicStampedReference.getReference());
}, "Thread 4").start();
}
}
输出结果:
=====以下时ABA问题的产生=====
true 2019
=====以下时ABA问题的解决=====
Thread 3 第1次版本号1
Thread 4 第1次版本号1
Thread 3 第2次版本号2
Thread 3 第3次版本号3
Thread 4 修改是否成功false 当前最新实际版本号:3
Thread 4 当前最新实际值:100
各种锁
公平锁与非公平锁
公平锁就是线程先来后到、非公平锁就是允许线程加塞
Lock lock = new ReentrantLock(Boolean fair);
默认为非公平。
- 公平锁是指多个线程按照申请锁的顺序来获取锁,类似排队。
- 非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程优先获取锁,在高并发的情况下,有可能会造成优先级反转或者饥饿现象。
二者区别:
-
公平锁:Threads acquire a fair lock in the order in which they requested it
公平锁,就是很公平,在并发环境中,每个线程在获取锁时,会先查看此锁维护的等待队列,如果为空,或者当前线程就是等待队列的第一个,就占有锁,否则就会加入到等待队列中,以后会按照FIFO的规则从队列中取到当前线程。
-
非公平锁:a nonfair lock permits barging: threads requesting a lock can jump ahead of the queue of waiting threads if the lock happens to be available when it is requested.
非公平锁比较粗鲁,直接尝试占有锁,如果尝试失败,就再采用类似公平锁那种方式。
对ReentrantLock而言,通过构造函数指定该锁默认是非公平锁,非公平锁的优点在于吞吐量比公平锁大。
Synchronized是一种非公平锁。
可重入锁
可重入锁(也叫作递归锁),指的时同一线程外层函数获得锁之后,内层递归函数仍然能获取该锁的代码,在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁,也就是说,线程可以进入任何一个它已经拥有的锁所同步着的代码块。可重入锁最大的作用是避免死锁。
ReentrantLock/Synchronized 就是典型的可重入锁
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockDemo {
public static void main(String[] args) {
Mobile mobile = new Mobile();
new Thread(mobile).start();
new Thread(mobile).start();
}
}
class Mobile implements Runnable{
Lock lock = new ReentrantLock();
@Override
public void run() {
get();
}
public void get() {
lock.lock();
lock.lock(); // 两把锁照样可以正常运行
try {
System.out.println(Thread.currentThread().getName()+"\t invoked get()");
set(); // set方法中的锁可重入,可以继续执行
}finally {
lock.unlock();
lock.unlock();
}
}
public void set(){
lock.lock();
try{
System.out.println(Thread.currentThread().getName()+"\t invoked set()");
}finally {
lock.unlock();
}
}
}
自旋锁
自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU。
手写一个自旋锁:
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
/**
* 实现自旋锁
* 自旋锁好处:循环比较获取知道成功位置,没有类似wait的阻塞
* 通过CAS操作完成自旋锁,A线程先进来调用mylock方法自己持有锁5秒钟,B随后进来发现当前有线程持有锁,不是null,所以只能通过自旋等待,知道A释放锁后B随后抢到
*/
public class SpinLockDemo {
public static void main(String[] args) {
SpinLockDemo spinLockDemo = new SpinLockDemo();
new Thread(() -> {
// 自己通过自旋方法生成的锁
spinLockDemo.mylock();
try {
TimeUnit.SECONDS.sleep(3);
}catch (Exception e){
e.printStackTrace();
}
spinLockDemo.myUnlock();
}, "Thread 1").start();
try {
TimeUnit.SECONDS.sleep(3);
}catch (Exception e){
e.printStackTrace();
}
new Thread(() -> {
spinLockDemo.mylock();
spinLockDemo.myUnlock();
}, "Thread 2").start();
}
//原子引用线程
AtomicReference<Thread> atomicReference = new AtomicReference<>();
public void mylock() {
Thread thread = Thread.currentThread();
System.out.println(Thread.currentThread().getName() + "\t come in");
// 自旋获取锁
while (!atomicReference.compareAndSet(null, thread)) {
}
}
public void myUnlock() {
Thread thread = Thread.currentThread();
// CAS解锁
atomicReference.compareAndSet(thread, null);
System.out.println(Thread.currentThread().getName()+"\t invoked myunlock()");
}
}
读写锁
-
独占锁(写锁):指该锁一次只能被一个线程所持有,对ReentrantLock和Synchronized而言都是独占锁;
-
共享锁(读锁):只该锁可被多个线程所持有;
ReentrantReadWriteLock其读锁是共享锁,写锁是独占锁
-
互斥锁:读锁的共享锁可以保证并发读是非常高效的,读写、写读、写写的过程是互斥的。
多个线程同时读一个资源类没有任何问题,所以为了满足并发量,读取共享资源应该可以同时进行。但是如果有一个线程象取写共享资源来,就不应该由其他线程对资源进行读或写。
代码示例:
public class MyCache {
private volatile Map<String, Object> map = new HashMap<>();
private ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
// 整个过程必须是一个完整的统一体,中间不许被分割,不许被打断
public void put(String key, Object value) {
// 写锁 独占锁
rwLock.writeLock().lock();
try {
TimeUnit.MILLISECONDS.sleep(300);
map.put(key, value);
} catch (Exception e) {
e.printStackTrace();
} finally {
rwLock.writeLock().unlock();
}
}
public void get(String key) {
// 读锁 共享锁
rwLock.readLock().lock();
try {
TimeUnit.MILLISECONDS.sleep(300);
Object result = map.get(key);
} catch (Exception e) {
e.printStackTrace();
} finally {
rwLock.readLock().unlock();
}
}
public void clear() {
map.clear();
}
}
ThreadLocal的原理
Thread为每个线程维护了ThreadLocalMap这么一个Map,在ThreadLocalMap中,也是用Entry来保存K-V结构数据的。但是Entry中key只能是每个ThreadLocal对象的弱引用。
static class Entry extends WeakReference<ThreadLocal> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal k, Object v) {
super(k);
value = v;
}
}
Entry继承自WeakReference(弱引用,生命周期只能存活到下次GC前),但只有Key是弱引用类型的,Value并非弱引用。
final域的内存语义
对于final域,编译器和处理器要遵守两个重排序规则:
- 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
- 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
目的:避免线程可能看到的final值被改变。