1 线程平安问题
在并发编程中,须要解决两个关键问题:线程之间如何通信及线程之间如何同步(这里的线程是指并发执行的流动实体)。
通信是指线程之间以何种机制来替换信息。Java中并发采纳的是共享内存模型,在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。
而同步是指程序中用于管制不同线程间操作产生绝对程序的机制。在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个办法或某段代码须要在线程之间互斥执行。
Java内存模型(Java Memory Model,JMM)形容了Java程序中各种变量(线程共享变量)的拜访规定,以及在JVM中将变量存储到内存和从内存中读取出变量这样的底层细节。
**
在Java中,所有实例域、动态域和数组元素都存储在堆内存中,堆内存在线程之间共享。因为线程的工作内存是线程公有内存,线程间无奈相互拜访对方的工作内存。所以线程 0 、线程 1 和线程 2须要读写主内存的共享变量
时,就都先将该共享变量拷贝(load)到本人的工作内存,而后在本人的工作内存中对该变量进行所有操作,线程工作内存对变量正本实现操作之后再将后果同步(save)至主内存。
因而,在线程上下文切换期间,多线程读写共享内存中的全局变量及动态变量容易引发竞态条件。
上面用代码来阐明:
@Slf4j
public class SafeTest {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 1; i < 5000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 1; i < 5000; i++) {
count--;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("count的值是: {}", count);
}
}
依照常理而言,count最终后果应该为0。屡次运行发现,最终count值还可能是负数,也可能为正数。这是为什么呢?
从字节码的层面进行剖析:
0 iconst_1
1 istore_0
2 iload_0
3 sipush 5000
6 if_icmpge 23 (+17)
9 getstatic #10 <com/kai/demo/basic/SafeTest.count> // 获取动态变量i的值
12 iconst_1 // 筹备常量1
13 iadd // 自增
14 putstatic #10 <com/kai/demo/basic/SafeTest.count> // 将批改后的值存入动态变量i
17 iinc 0 by 1
20 goto 2 (-18)
23 return
0 iconst_1
1 istore_0
2 iload_0
3 sipush 5000
6 if_icmpge 23 (+17)
9 getstatic #10 <com/kai/demo/basic/SafeTest.count> // 获取动态变量i的值
12 iconst_1 // 筹备常量1
13 isub // 自减
14 putstatic #10 <com/kai/demo/basic/SafeTest.count> // 将批改后的值存入动态变量i
17 iinc 0 by 1
20 goto 2 (-18)
23 return
可见count++
和 count--
操作理论都是须要这个4个指令实现的,那么这里问题就来了!Java 的内存模型如下,实现动态变量的自增、自减须要在主存和工作内存中进行数据交换。
失常程序执行:
理论呈现正数的状况:
理论呈现负数的状况:
像下面count++
和 count--
的代码所在区域又称临界区(一段代码内如果存在对共享资源的多线程读写操作,那么称这段代码为临界区)。
跟下面案例一样。如果多个线程临界区代码执行竞争同一资源时,对资源的拜访程序敏感,执行时序的不同导致会呈现某种不失常的行为,就称存在竞态条件(Race Condition)。
为防止竞态条件的呈现,保障java共享内存的原子性、可见性、有序性,很有必要放弃线程同步。Java中提供了synchronized、volatile关键字与Lock
类。
2 初识synchronized
synchronized采纳互斥同步(Mutual Exclusion & Synchnronization)的形式,让多个线程并发访问共享数据时,保障共享数据在同一时刻只被一个(或一些,应用信号量的时候)线程应用。互斥是实现同步的一种伎俩,临界区、互斥量和信号量都是次要的互斥实现形式。因而在互斥同步四个字中,互斥是因,同步是果;互斥是办法,同步是目标。
2.1 应用场景
在Java代码中应用synchronized可应用在代码块和办法中,依据Synchronized用的地位能够有这些应用场景:
可见,synchronized的应用场景次要有3种:
- 润饰静态方法,给以后类对象加锁,进入同步办法时须要取得类对象的锁;
- 润饰实例办法,给以后实例变量加锁,进入同步办法时须要取得以后实例的锁;
- 润饰同步办法块,指定加锁对象(实例对象/是类变量),进入同步办法块时须要取得加锁对象的锁。
2.2 案例剖析
上面,将synchronized利用到下面的案例中:
@Slf4j
public class SafeTest {
static int count = 0;
static final Object lock = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 1; i < 5000; i++) {
synchronized (lock){
count++;
}
}
});
Thread t2 = new Thread(() -> {
for (int i = 1; i < 5000; i++) {
synchronized (lock){
count--;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("count的值是: {}", count);
}
}
屡次测试发现,后果都为0。这是因为synchronized利用对象锁保障了临界区代码的原子性,临界区内的代码在外界看来是不可分割的,不会被线程切换所打断。
synchronized为什么这么神奇,它到底做了什么呢?上面就进一步探讨一下synchronized的实现原理。
3 synchronized原理
先来理解两个重要的概念:“Java对象头”、”锁记录”。
3.1 Java对象头
对象实例化内存布局与拜访定位中形容了,JVM中对象内存布局次要分为三块区域:对象头区、实例数据区和填充区。
<img src=”https://img-blog.csdnimg.cn/202010111456447.jpg” style=”zoom: 67%;” />
Synchronized用到的锁就是存在Java对象头里的。对象头区又次要分为两局部,别离是 运行时元数据(Mark Word)和 类型指针。
如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit。 Java对象头具体构造形容如下:
Mark Word用于存储对象本身的运行时数据,如:哈希码(HashCode)、GC分代年龄、锁状态标记、线程持有的锁、偏差线程 ID、偏差工夫戳等。32位JVM的Mark Word的默认存储构造如下:
在运行期间,Mark Word里存储的数据会随着锁标记位的变动而变动。Mark Word可能变动为存储以下4种数据:
在64位虚拟机下,Mark Word是64bit大小的,其存储构造如下:
3.2 锁记录
在线程进入同步代码块的时候,如果此同步对象没有被锁定,它的锁标记位是01,则虚拟机首先在以后线程的栈中创立咱们称之为“锁记录(Lock Record)”的空间,用于存储锁对象的Mark Word的拷贝,官网把这个拷贝称为Displaced Mark Word。整个Mark Word及其拷贝至关重要。
Lock Record是线程公有的数据结构,每一个线程都有一个可用Lock Record列表,同时还有一个全局的可用列表。每一个被锁住的对象Mark Word都会和一个Lock Record关联(对象头的Mark Word中的Lock Word指向Lock Record的起始地址),同时Lock Record中有一个Owner字段寄存领有该锁的线程的惟一标识(或者object mark word
),示意该锁被这个线程占用。
Lock Record | 形容 |
---|---|
Owner | 初始时为NULL示意以后没有任何线程领有该monitor record,当线程胜利领有该锁后保留线程惟一标识,当锁被开释时又设置为NULL。 |
EntryQ | 关联一个零碎互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。 |
RcThis | 示意blocked或waiting在该monitor record上的所有线程的个数。 |
Nest | 用来实现重入锁的计数。 |
HashCode | 保留从对象头拷贝过去的HashCode值(可能还蕴含GC age)。 |
Candidate | 用来防止不必要的阻塞或期待线程唤醒,因为每一次只有一个线程可能胜利领有锁,如果每次前一个开释锁的线程唤醒所有正在阻塞或期待的线程,会引起不必要的上下文切换(从阻塞到就绪而后因为竞争锁失败又被阻塞)从而导致性能重大降落。Candidate只有两种可能的值0示意没有须要唤醒的线程1示意要唤醒一个继任线程来竞争锁 |
3.3 Monitor原理
Monitor,常被翻译为“监视器”或者“管程”。
操作系统在面对过程/线程间同步时,所反对的最重要的同步原语即是semaphore 信号量 和 mutex 互斥量。在应用根本的 mutex 进行并发管制时,须要程序员十分小心地管制 mutex 的 down 和 up 操作,否则很容易引起死锁等问题。为了更容易地编写出正确的并发程序,在 mutex 和 semaphore 的根底上,提出了更高层次的同步原语 Monitor。
不过须要留神的是,操作系统自身并不反对 Monitor机制,Monitor是属于编程语言的领域。例如C语言它就不反对 monitor,Java 语言反对 Monitor。Java对象则是天生的Monitor,每一个Java对象都有成为Monitor的“潜质”。这是为什么呢?
因为在Java的设计中,每一个对象自打娘胎里进去,就带了一把看不见的锁,通常咱们叫“外部锁”,或者“Monitor锁”,或者“Intrinsic lock”。有了这个锁的帮忙,只有把类的对象办法或者代码块用synchronized关键字润饰,就会先获取到与 synchronized 关键字绑定在一起的 Object 的对象锁,这个锁会限定其它线程进入与这个锁相干的synchronized 代码区域。而这个对象锁,也就是一个货真价实的Monitor。
因而,能够把Monitor了解为一种同步工具,也能够了解是一种同步机制,它通常被形容为一个对象。其次要特点有:
- 对象的所有办法都被“互斥”的执行。也就是说,同一个时刻,只有一个 过程/线程 能进入 Monitor 中定义的临界区。
- 通常提供singal机制。即容许正持有“许可”的线程临时放弃“许可”,期待某个谓词成真(条件变量),而条件成立后,以后过程能够“告诉”正在期待这个条件变量的线程,让他能够从新去取得运行许可。
应用synchronized
给对象上锁时,该对象头的Mark Word中就被设置为指向Monitor对象的指针。Mark Word锁标识位为10,其中指针指向的是Monitor对象的起始地址。在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,其次要数据结构如下(位于HotSpot虚拟机源码objectMonitor.hpp文件,C++实现的):
// initialize the monitor, exception the semaphore, all other fields
// are simple integers or pointers
ObjectMonitor() {
_header = NULL;
_count = 0; // 记录个数
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; // 处于wait状态的线程,会被退出到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; // 处于期待锁block状态的线程,会被退出到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保留ObjectWaiter对象列表( 每个期待锁的线程都会被封装成ObjectWaiter对象 ),_owner指向持有ObjectMonitor对象的线程,当多个线程同时拜访一段同步代码:
- 线程须要获取 Object 的锁时,会被放入 EntrySet(入口区) 中进行期待(enter)。
- 如果该线程获取到了锁(acquire),成为以后锁的 Owner。
- 如果依据程序逻辑,一个曾经取得了锁的线程短少某些内部条件,而无奈持续进行上来(例如生产者发现队列已满或者消费者发现队列为空),那么该线程能够通过调用 wait 办法将锁开释(release),进入 Wait Set (期待区)中阻塞(BLOCKED)进行期待。
- 其它线程在这个时候有机会取得锁,从而使得之前不成立的内部条件成立,这样先前被阻塞的线程就能够从新进入 EntrySet 去竞争锁(acquire)。这个内部条件在 Monitor 机制中称为条件变量。
Tips:因为进入期待区只有号入口,由此能够推断,一个线程只有在持有监视器时能力执行wait操作,处于期待的线程只有再次取得监视器能力退出期待状态。
上面再从字节码角度了解一下Monitor原理:
public class Test {
static int counter = 0;
static final Object lock = new Object();
public static void main(String[] args) {
synchronized (lock) {
counter++;
}
}
}
应用javap 命令反编译class文件: javap -v Test.class
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0 getstatic #2 <com/kai/demo/basic/Test.lock> // <- 获得lock的援用(synchronized开始)
3 dup // 复制了一份lock长期援用
4 astore_1 // lock长期援用 -> 存入局部变量表slot 1中
5 monitorenter // 将lock对象的Mark Word置为指向Monitor指针
6 getstatic #3 <com/kai/demo/basic/Test.counter> // <- i
9 iconst_1 // 筹备常数1
10 iadd // +1
11 putstatic #3 <com/kai/demo/basic/Test.counter> // -> i
14 aload_1 // <- 获得lock长期援用,放入操作数栈栈顶
15 monitorexit // 将lock对象的Mark Word重置,唤醒EntryList
16 goto 24 (+8) // 执行到24行,代码完结
//上面是异样解决指令。可见,如果出现异常,也能主动地开释锁。
19 astore_2 // exception -> slot 2
20 aload_1 // <- 获得lock的援用
21 monitorexit // 将lock对象的Mark Word重置,唤醒EntryList
22 aload_2 // <- slot 2(exception)
23 athrow // throw(exception)
24 return
Exception table:
from to target type
6 16 19 any // 异样检测6-16行代码(即长期区)
19 22 19 any
LineNumberTable:
line 22: 0
line 23: 6
line 24: 14
line 25: 24
LocalVariableTable:
Start Length Slot Name Signature
0 25 0 args [Ljava/lang/String;
--- omit ---
执行同步代码块后首先要先执行monitorenter指令,退出的时候monitorexit指令。
通过剖析之后能够看出,应用Synchronized进行同步,其要害就是必须要对对象的监视器monitor进行获取,当线程获取monitor后能力持续往下执行,否则就只能期待。而这个获取的过程是互斥的,即同一时刻只有一个线程可能获取到monitor。过程大抵如下:
- 如果monitor的进入数为0,则该线程进入monitor,而后将进入数设置为1,该线程即为monitor的所有者;
- 如果线程曾经占有该monitor,如果从新进入,则进入monitor的进入数加1(锁的重入性);
- 如果其余线程曾经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再从新尝试获取monitor的所有权。
monitorexit指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其余被这个monitor阻塞的线程能够尝试去获取这个 monitor 的所有权。
下面案例中,monitorexit指令呈现了两次,第1次为同步失常退出开释锁,第2次为产生异步退出开释锁。
通过下面两段形容,咱们应该能很分明的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来实现。
须要留神的是,synchronized用在同步办法上时,字节码指令中不会呈现monitorenter和monitorexit指令。
例如:
public class Test1 {
public synchronized void method() {
System.out.println("Hello World!");
}
}
应用javap 命令反编译class文件:javap -v Test1.class
public synchronized void method();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Hello World!
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 11: 0
line 12: 8
--- omit ---
办法的同步并没有通过指令 monitorenter
和 monitorexit
来实现,不过绝对于一般办法,其常量池中多了 ACC_SYNCHRONIZED
标示符。JVM就是依据该标示符来实现办法的同步的:
当办法调用时,调用指令将会查看办法的 ACC_SYNCHRONIZED 拜访标记是否被设置,如果设置了,执行线程将先获取monitor,获取胜利之后能力执行办法体,办法执行完后再开释monitor。在办法执行期间,其余任何线程都无奈再取得同一个monitor对象。
两种同步形式实质上没有区别,只是办法的同步是一种隐式的形式来实现,无需通过字节码来实现。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、期待从新调度,会导致“用户态和内核态”两个态之间来回切换。
4 synchronized进阶
4.1 重量级锁
在JDK 6之前,synchronized通过监视器(Monitor)来实现线程同步,然而Monitor实质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统要实现线程之间的切换须要从用户态转换到外围态,转换工夫绝对比拟长,老本也十分高。因而,起初称这种锁为“重量级锁”。
JDK 6为了缩小取得锁和开释锁带来的性能耗费,引入了“偏差锁”和“轻量级锁”。所以,目前锁一共有4种状态,级别从低到高顺次是:无锁状态、偏差锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争状况逐步降级。锁能够降级但不能降级,意味着偏差锁升级成轻量级锁后不能降级成偏差锁。这种锁降级却不能降级的策略,目标是为了进步取得锁和开释锁的效率。
四种锁状态对应的的Mark Word内容形容如下:
在64位虚拟机下,Mark Word在不同锁状态存储构造如下:
4.2 自旋
4.2.1 自旋锁
线程的阻塞和唤醒须要CPU从用户态切转为外围态,而且这种切换不易优化。如果锁的粒度很小,即锁持有的工夫很短的时候。由锁竞争造成频繁地阻塞和唤醒线程就显得十分不值得,因而引入了自旋锁。
自旋锁能够缩小线程阻塞造成的线程切换。其执行步骤如下:
- 以后线程尝试去竞争锁。
- 竞争失败,筹备阻塞本人。
- 然而并没有阻塞本人,进入自旋状态(空期待,比方一个空的无限for循环)。
- 自旋状态下,持续竞争锁。
- 如果自旋期间胜利获取锁,那么完结自旋状态,否则进入阻塞状态。
如果在自旋期间胜利获取锁,那么就缩小一次线程的切换。
可见,如果持有锁的线程很快就开释了锁,那么自旋的效率就十分好,反之,自旋的线程就会白白消耗掉解决的资源。所以自旋锁适宜在持有锁工夫短,并且竞争强烈的场景下应用。
在JDK1.6中自旋锁默认开启。能够应用-XX:+UseSpinning
开启,-XX:-UseSpinning
敞开自旋锁优化。
自旋的默认次数为10次,能够应用-XX:preBlockSpin
参数批改默认的自旋次数。
4.2.2 适应性自旋锁
适应性自旋,是赋予了自旋一种学习能力,它并不固定自旋10次。他能够依据它后面线程的自旋状况,从而调整它的自旋。
例如,线程总是自旋胜利,那么虚拟机就会容许自旋期待继续的次数更多。反之,如果对于某个锁,很少有自旋可能胜利,那么在当前竞争这个锁的时,自旋的次数会缩小甚至省略掉自旋过程间接进入阻塞状态,免得节约处理器资源。
4.3 锁打消与锁粗化
4.3.1 锁打消
JVM会对不会存在线程平安的锁进行锁打消。例如应用JDK的内置API,如StringBuffer、Vector、HashTable等会存在隐形的加锁操作。
public void vectorTest(){
Vector<String> vector = new Vector<String>();
for(int i = 0 ; i < 10 ; i++){
vector.add(i + "");
}
System.out.println(vector);
}
运行这段代码时,JVM显著检测到变量vector没有逃逸出办法vectorTest()之外,所以JVM会大胆地将vector外部的加锁操作打消。
锁打消的根据是逃逸剖析的数据反对。
4.3.2 锁粗化
在遇到一连串地对同一锁一直进行申请和开释的操作时,JVM会把所有的锁操作整合成锁的一次申请,从而缩小对锁的申请同步次数,这个操作叫做锁的粗化。
例如:
for(int i = 0 ; i < 100 ; i++){
synchronized(lock){
// 同步块
}
}
锁粗化后:
synchronized(lock){
for(int i = 0 ; i < 100 ; i++){
// 同步块
}
}
4.4 偏差锁
在大多数状况下,锁不仅不存在多线程竞争,而且总是由同一线程屡次取得,为了缩小此类情况下线程取得锁的性能耗费,JDK6中引进了偏差锁。
当一个线程拜访同步代码块并获取锁时,会在Mark Word里存储锁偏差的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向以后线程的偏差锁。引入偏差锁是为了在没有多线程竞争的状况下尽量减少不必要的轻量级锁执行门路,偏差锁只须要在置换ThreadID的时候依赖一次CAS原子指令即可。
偏差锁只有遇到其余线程尝试竞争偏差锁时,持有偏差锁的线程才会开释锁,线程不会被动开释偏差锁。偏差锁的撤销,须要期待全局平安点(在这个工夫点上没有字节码正在执行),它会首先暂停领有偏差锁的线程,判断锁对象是否处于被锁定状态。撤销偏差锁后复原到无锁(标记位为“01”)或轻量级锁(标记位为“00”)的状态。
偏差锁在JDK 6及当前的JVM里是默认启用的。能够通过JVM参数敞开偏差锁:-XX:-UseBiasedLocking=false
,敞开之后程序默认会进入轻量级锁状态。
上面用OpenJDK 的 JOL 包来做试验,先增加 maven 依赖:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
定义一个一般的 Java 对象:
public class Person {
String str = "";
Son son = new Son();
}
class Son {
}
利用 JOL 包下的 ClassLayout 来输入他的内存布局:
@Slf4j
public class BiasedLockTest {
public static void main(String[] args) {
Person person = new Person();
log.debug(ClassLayout.parseInstance(person).toPrintable());
}
}
打印后果如下:
图中的1、2、3、4别离对应 MarkWord、类型指针、实例数据、对齐填充。
- MarkWord:共8字节,该对象刚新建,锁标识位是 01,处于无锁状态。
- 类型指针:共4字节,标识新建的 person 对象。
- 实例数据:共8字节,定义的 Person 有两个属性,str 和 son对应的类型别离为 String 和 Son,这两个属性各占4个字节。
- 对齐填充:共4个字节,前三个局部字节相加为8+ 4+ 8 = 20,不是8的整数倍,所以得填充4个字节凑齐24字节。形容信息中也有阐明:
loss due to the next object aligment
。
下面提到,偏差锁在JDK 6及当前的JVM里是默认启用的。那为什么启动后标记地位是“001”无锁状态而不是“101”偏差锁状态呢?这是因为偏差锁默认是提早加载的,不会在程序启动的时候立即失效,能够通过JVM参数来防止提早:-XX:BiasedLockingStartupDelay=0
。
再次运行:
4.5 轻量级锁
偏差锁多利用只有一个线程拜访同步块场景中,一旦偏差锁被其余线程拜访,就会降级为轻量级锁。其余线程会通过自旋的模式尝试获取锁,不会阻塞,从而进步性能。
应用轻量级锁的多线程之间不存在锁竞争,线程是交替执行同步块的。引入轻量级锁的目标正是在没有多线程竞争的前提下,缩小传统的重量级锁应用操作系统互斥量产生的性能耗费。
轻量级锁加锁过程如下:
- 在代码块进入同步块时,如果同步对象锁状态为无锁状态(锁标记位01,是否偏差锁0),虚拟机首先将在以后线程的栈帧中建设一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官网称之为Displaced Mark Word。
- 拷贝对象头中的Mark Word复制到锁记录中。
- 拷贝胜利后,虚拟机将应用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向对象的Mark Word。
- 如果更新动作胜利了,那么这个线程就领有了该对象的锁,此时对象Mark Word锁标记位设置为00,示意此对象处于轻量级锁定状态。
- 如果更新操作失败了,虚拟机首先会查看对象的Mark Word是否指向以后线程的栈帧,如果是,阐明以后线程曾经领有了这个对象的锁,那么可用间接进入同步块继续执行。否则阐明有多个线程竞争锁,若以后只有一个期待线程,则线程会通过自旋进行期待;但当自旋超过肯定次数或者一个线程持有锁,一个在自旋,又来了第三个线程竞争锁,那么轻量级锁会收缩降级为重量级锁,锁标记位设置为10。
4.6 锁收缩/锁降级
锁降级过程:无锁—>偏差锁—>轻量级锁—>重量级锁。具体如下:
5 简析CAS
CAS全称 Compare And Swap(比拟与替换),是一种无锁算法。在不应用锁(没有线程被阻塞)的状况下实现多线程之间的变量同步。
CAS算法波及到三个操作数:
- V 内存地址寄存的理论值
- A 比拟的旧值
- B 更新的新值
当且仅当V的值等于A时(旧值和内存中理论的值雷同),表明旧值A曾经是目前最新版本的值,自然而然能够将新值 N 赋值给 V。反之则表明V和A变量不同步,间接返回V即可。当多个线程应用CAS操作一个变量时,只有一个线程会更新胜利,其余失败的线程会从新尝试。也就是说,“更新”是一个一直重试的操作。
进入原子类AtomicInteger的源码,看一下AtomicInteger的定义:
public class AtomicInteger extends Number implements java.io.Serializable {
private static final long serialVersionUID = 6214790243416807050L;
// 获取并操作内存的数据。
private static final Unsafe unsafe = Unsafe.getUnsafe();
// 存储value在AtomicInteger中的偏移量。
private static final long valueOffset;
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
// 存储AtomicInteger的int值,该属性须要借助volatile关键字保障其在线程间是可见的。
private volatile int value;
接下来,咱们查看AtomicInteger的自增函数incrementAndGet()的源码时,发现自增函数底层调用的是unsafe.getAndAddInt()。
// AtomicInteger 自增办法
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
// Unsafe.class
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
compareAndSwapInt
这个函数,它也是CAS
缩写的由来。通过OpenJDK 8 来查看Unsafe.cpp的源码:
// Unsafe.java
public final int getAndAddInt(Object o, long offset, int delta) {
int v;
do {
v = getIntVolatile(o, offset);
} while (!compareAndSwapInt(o, offset, v, v + delta));
return v;
}
依据OpenJDK 8的源码咱们能够看出,getAndAddInt()循环获取给定对象o中的偏移量处的值v,而后判断内存值是否等于v。如果相等则将内存值设置为 v + delta,否则返回false,持续循环进行重试,直到设置胜利能力退出循环,并且将旧值返回。整个“比拟+更新”操作封装在compareAndSwapInt()中,在JNI里是借助于一个CPU指令实现的,属于原子操作,能够保障多个线程都可能看到同一个变量的批改值。
后续JDK通过CPU的cmpxchg指令,去比拟寄存器中的 A 和 内存中的值 V。如果相等,就把要写入的新值 B 存入内存中。如果不相等,就将内存值 V 赋值给寄存器中的值 A。而后通过Java代码中的while循环再次调用cmpxchg指令进行重试,直到设置胜利为止。
6 写在最初
在并行编程过程中,很容易产生线程平安问题,比方多线程读写共享内存中的全局变量及动态变量时引发的竞态条件。
咱们能够应用synchronized关键字来放弃线程同步,防止上述问题产生。而synchronized是基于Monitor 机制实现的,然而Monitor实质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统要实现线程之间的切换须要从用户态转换到外围态,转换工夫绝对比拟长,老本也十分高。因而,起初称这种锁为“重量级锁”。
JDK 6为了缩小取得锁和开释锁带来的性能耗费,引入了“偏差锁”和“轻量级锁”。所以,目前锁一共有4种状态,级别从低到高顺次是:无锁状态、偏差锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争状况逐步降级,性能开销也逐步增大。这4种锁并不是互相代替的关系,它们只是在不同场景下的不同抉择。
锁 | 长处 | 毛病 | 实用场景 |
---|---|---|---|
偏差锁 | 加锁和解锁不须要额定耗费, 和执行非同步办法相比仅仅存在纳秒级的差距。 |
线程间存在锁竞争, 会带来额定的锁撤销的耗费。 |
实用于只有一个线程拜访同步块场景。 |
轻量级锁 | 竞争的线程不会阻塞, 进步了线程的响应速度。 |
如果始终得不到锁竞争的线程,<br/>应用自旋会耗费CPU。 | 谋求响应速度,<br/>同步块执行速度十分快。 |
重量级锁 | 线程竞争不会应用自旋,<br/>不会耗费CPU。 | 线程阻塞,响应工夫迟缓。 | 谋求吞吐量,<br/>同步块执行工夫较长。 |
参考资料
【JAVA学习笔记】多线程
JAVA并发编程的艺术
让你彻底了解Synchronized
Java 中的 Monitor 机制
深入分析Synchronized原理
不可不说的Java“锁”事
对象的内存布局(JOL)和锁
发表回复