目录
1、原子性问题
一千个线程循环计数,最终结果总是小于等于1000的随机数。.
public class Count {
public static int count = 0;
public static void incr() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
new Thread(() -> Count.incr()).start();
}
Thread.sleep(2000);
System.out.println("结果:" + count);
}
}
2、锁
在java中,加锁需要使用synchronized关键字,锁的本质就是对于共享资源访问的一个限制,它让同一时间内只有一个线程能访问这个共享资源,以此确保多线程并发的原子性操作,因此对于synchronized而言,加锁是有作用范围的,范围就是共享资源的使用范围。
2.1、实例锁
修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁,只针对于当前对象实例有效。
public class SynchronizedDemo {
synchronized void method1() {
}
void method2() {
synchronized (this) {
}
}
}
2.2、类锁
静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁,针对所有对象都互斥,因为静态方法是唯一的,所以在静态方法上加锁也是类锁。
public class SynchronizedDemo {
synchronized static void method3() {
}
void method4() {
synchronized (SynchronizedDemo.class) {
}
}
}
2.3、代码块
修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁,如单例模式给HashMap加锁。
public class SynchronizedDemo {
Object object = new Object();
void method5() {
synchronized (object) {
}
}
}
3、锁的存储
synchronize关键字是对某一个对象进行加锁,那么肯定会在某个位置上具有标记,线程可以根据标记判断是否加锁,那么可以确定的就是,标记应该在加锁对象上。
在java中对某一个对象加锁之后,在这个对象的JVM层面的存储结构中,对象头保存有关于锁的信息。
下表是32位存储的内容,64位与32位的存储是几乎没有差别的,可以看到在表中,除了无锁状态之外,还有三种锁,那么一个synchronized具有三种锁类型吗?
4、锁的类型
当两个线程抢占资源的时候,会经历三种类型的锁:偏向锁,轻量级锁,重量级锁,它们是同一把锁的三种状态。
一定要意识到,锁本身就意味着额外的性能消耗,因此为了提升性能,最好的办法是无锁,因此java对synchronized做出了一些优化,三种锁类型就是优化结果。
但是在了解synchronized之前,需要了解一下乐观锁与悲观锁
4.1、乐观锁
乐观锁的预期是乐观的,它默认不会有人修改数据,但是它又无法避免真的可能会有人修改数据,所以它将会比较预期数据和原始数据是否一致,如果一致就修改,不一致就修改失败,下文中CAS就是乐观锁的思想,意为比较并替换。
乐观锁会出现ABA的情况,即数据A被改为B,又被改为A,乐观锁无法发现数据被修改,为解决此问题,可以使用版本迭代的方式来检测是否被篡改过数据,在进行修改时版本上升,在进行比较时同时比较数据和版本
CAS在许多的实现下,其操作必须是原子性的,因为其使用在多线程情况下,进行的本质是两个步骤,比较并替换,这个步骤必须被合成一个原子性操作以保证线程安全。在JAVA实现中,常采用四个参数,object,offset,A,B。offset是偏移量,和Object组合获得内存中的lock_flag,A是预期值,B是要更新的数值,以此做到原子操作。
在CAS上,依旧会有lock,这个lock并非JAVA的锁,而是类似总线锁的操作,以确保多CPU下的安全,此与可见性问题有关。
4.2、悲观锁
悲观锁的预期是悲观的,它默认会有人修改数据,所以它会先加锁,然后修改数据。
5、同步锁状态转换
5.1、无锁
5.1.1、基础信息
无锁状态的锁标记为01,偏向锁标记为0,因此mark word的后三位为001。
5.1.2、流程分析
在关闭偏向锁时,新创建的对象都属于无锁不可偏向状态。
在开启偏向锁时,在启动项目的前4秒内,新建的对象都是无锁不可偏向状态,因为jvm会有4秒的偏向锁开启的延迟时间,在此期间,偏向锁不会开启,4秒钟后创建的对象才是具有偏向锁的对象。
为什么要强调是无锁不可偏向状态,为什么偏向锁会延迟启动,其实原因是一致的,因为刚刚开启JVM时,大量对象生成后一定会产生竞争,添加偏向锁的对象性能而会严重下降,既然竞争一定会发生,不如直接添加轻量级锁。
5.1.3、升级总结
无锁状态可以详细称为无锁不可偏向状态,如果加锁将会直接跳过偏向锁,进入到轻量级锁状态。
匿名偏向锁可以详细称为无锁可偏向状态,因为此时还没有线程抢占到这把锁。
5.2、偏向锁
5.2.1、基础信息
偏向锁的锁标记为01,偏向锁标记为1,因此mark word的后三位为101,在存储hashcode的位置,将会存储ThreadId。
偏向锁属于乐观锁,会在加锁但没有产生竞争的情况下使用,进行一次CAS,在1.6与1.7默认开启的偏向锁在1.8默认关闭。
偏向锁的思想是,记录第一个进入偏向锁的线程ID,当第二个线程进入时,进行线程ID的比较,如果一致就可以继续持有锁,如果不一致,将发生偏向锁的撤销,膨胀或是重偏向等动作。
5.2.2、流程分析
当一个线程进入到synchronized的代码块时,就会从这个线程当前的栈找到一个空闲的BasicObjectLock(Lock Record列表)。这是一个基础的锁对象,在后续的轻量级锁和重量级锁中都会用到。BasicObjectLock中包含两个属性:
- BasicLock:该属性中有一个字段markOop(对象标记),用于保存指向lock锁对象的对象头数据。对象标记存储对象本身运行时的数据,如哈希码、GC标记、锁信息、线程关联信息等,这部分数据在64位JVM上占用8个字节,称为Mark Word(对象头),详细情况可以查看锁的存储这一节。
- oop:指向lock锁对象的指针
将BasicObjectLock的oop指针指向当前的锁对象
获得当前锁对象的对象头,通过对象头判断是否可以进行偏向。
对于偏向锁,对象头内的信息无非就是三个,ThreadId,偏向锁标记,锁标记,锁标记固定为01,因此只需要查看ThreadId与偏向锁标记即可。
- 当偏向锁标记为0,此时为无锁状态,不可偏向,需要轻量级锁来完成抢占
- 偏向锁标记为1,此时为偏向锁,有线程尝试获取锁时,执行CAS操作
接下来,就需要使用CAS进行判断并比较,此时会发生两种情况
- 如果一致就只需要在lock record上产生一次记录,增加重入次数。
- 如果不一致,将执行CAS操作,将mark word中的ThreadId替换为当前线程的ID,添加时间戳epoch
只有两种情况下才能成功替换
- 对象的偏向锁为匿名偏向锁
- 批量重偏向时,对象处于可重偏向的状态,新线程可以使用CAS将ThreadId替换为自己的ID
当ThreadId为0,偏向锁标记为1,此时为匿名偏向锁。在关闭jvm偏向锁延迟或4秒后创建的对象将会具有偏向锁,但是此时并无线程可以持有这个锁,因此此时的偏向锁为匿名偏向锁。匿名偏向锁是偏向锁的初始状态。
批量重偏向放在之后讲解。
如果不符合这两种情况,CAS操作会失败,证明当前线程存在竞争,替换失败后,会执行偏向锁撤销操作。偏向锁撤销需要等到全局安全点,在全局安全点,所有线程都会暂停,此时获得偏向锁的线程会被挂起。
在安全点,所有线程都会被遍历,检查持有偏向锁的线程是否保持存活。
- 如果依旧存活,且线程正在执行同步代码块中的代码,则升级成为轻量级锁
- 如果已经销毁,或持有偏向锁的线程未执行同步代码块中的代码,则进行校验,判断是否允许重偏向
- 如果不允许重偏向,则撤销偏向锁,升级为轻量级锁,继续CAS自旋竞争。
- 如果允许重偏向,则将其设置为匿名偏向锁状态,再通过CAS重新指向新的线程
完成操作后,从安全点继续执行代码。
代码执行完成,退出同步块,同步锁释放。此时线程将会逐条删除记录,释放Lock Record,但是需要注意,所谓的偏向锁释放并不是真正的释放,线程ID依然保存在ThreadID之内,只是记录消失,该偏向锁依旧偏向此线程。
5.2.3、升级总结
向锁升级为轻量级锁之后,执行完同步代码块,锁被释放,对象状态将变为无锁不可偏向状态,即001,符合锁只要升级膨胀就不能回退的要求。
偏向锁升级为重量级锁也是可能的。
- 当对象调用wait时,偏向锁将直接升级为重量级锁,因为wait的使用必须释放锁,所谓释放锁就是要先获得锁,这个锁指的就是重量级锁ObjectMonitor,其中包含了wait对应指令内容,这个到重量级锁再行阐述。
- 当对象调用hashcode()方法,会导致偏向锁回退至无锁不可偏向状态,以后再加锁也都是重量级锁,因为原本mark word中存储ThreadId的位置就是存HashCode的位置,在偏向锁情况下仅能存储一个,只有重量级锁ObjectMonitor才会存储hashcode的数值。
5.2.4、批量重定向
在未禁用偏向锁的情况下,假设一个线程针对大量对象设置了偏向锁,之后其他线程来访问这些对象,在不触发锁竞争的情况下,也需要对这些对象进行偏向锁的撤销和锁升级膨胀,因此虚拟机会认为,此时对象的偏向是有问题的,当偏向锁针对一个线程的撤销发生20次之后,就会触发批量重偏向,将其余被访问对象的偏向锁重新指向新线程,即前19次的偏向锁依旧会发生偏向锁撤销和膨胀为轻量级锁,第20个之后被访问的对象就会直接重偏向至新线程,未被新线程访问的对象偏向锁保持不变。
当此对象的偏向锁继续被撤销,达到40次之后,就会触发批量撤销,JVM认为该class的使用场景存在多线程竞争,将其余具有偏向锁的对象执行偏向锁撤销,同时标记该class为不可偏向,之后再创建此class的对象就直接为无锁不可偏向状态,即直接走轻量级锁的逻辑。
下面是几个重偏向的参数
- BiasedLockingBulkRebiasThreshold:偏向锁批量重偏向阈值,默认为20次
- BiasedLockingBulkRevokeThreshold:偏向锁批量撤销阈值,默认为40次
- BiasedLockingDecayTime:重置计数的延迟时间,默认值为25000毫秒(即25秒)
在JVM中,以class为单位,为每一个class维护了一个偏向锁撤销计数器,当这个对象发生偏向锁撤销时,计数器会进行累加,当超过阈值,就会触发批量重偏向。
一般情况下,class中的epoch与对象的epoch是一致的,当发生批量重偏向时,首先会将class的epoch值+1,接着遍历所有当前活着的的线程的栈,找到该class所有正处于偏向锁状态的锁实例对象,将epoch值修改为新值。此时其他未被线程持有的锁对象epoch会比class的epoch小,在其他线程获取到该锁对象时,会尝试使用CAS执行替换重偏向。
距离上次批量重偏向的25秒内,如果撤销计数达到40,就会发生批量撤销,如果超过25秒,那么就会重置计数。
如果超过25秒没有达到批量撤销累加计数,证明上次的批量重偏向效果显著,将会重置计数器计数,如果超过了,证明这个class不适合偏向锁。
5.3、轻量级锁
5.3.1、基础信息
轻量级锁的锁标记为00,因此mark word的后两位为00。
轻量级锁属于乐观锁,会在共享资源被抢占的时候使用,当线程发现锁已经被抢占,将导致该线程的锁膨胀,成为重量级锁。
5.3.2、流程分析
当一个线程进入到synchronized的代码块时,就会从这个线程当前的栈找到一个空闲的BasicObjectLock(Lock Record列表),在BasicLock中有一个成员属性markOop_displaced_header,这个属性专门用来保存对象的原始对象头Mark Word。
将对象的无锁状态Mark Word保存到_displaced_header中。
检测是否为无锁状态,如果是,通过CAS将对象的Mark Word 替换为指向Lock Record 的指针,如果替换成功,就表示抢占成功。
如果CAS失败,就表示此对象不为无锁状态,需要判断对象指向Lock Record 的指针
- 不一致,将会触发锁膨胀,升级为重量级锁。
- 一致,将会进行一次重入
轻量级锁的释放同样使用了CAS操作。
尝试将Lock Record的displaced_header存储的 mark word 替换回对象mark word,此操作使用CAS进行,这时需要检查锁对象的mark word中lock record指针是否指向当前线程的锁记录:
- 如果替换成功,则表示没有竞争发生,整个同步过程就完成了
- 如果替换失败,则表示当前锁资源存在竞争,触发锁膨胀,完成锁膨胀之后,调用重量级锁的解锁方式,完成锁的释放
为什么会释放失败?因为轻量级锁在被某个线程占有时,会被其他线程尝试抢占,如果无法获得轻量级锁,就会触发锁膨胀。锁膨胀的逻辑是抢占线程会在判定此时为轻量级锁的情况下,修改锁对象的Mark Word,设置状态为inflating状态。这个操作是通过自旋实现的,多个线程触发膨胀也只会有一个线程修改状态。
因为被修改了状态,所以轻量级锁释放必然会导致失败,对象的锁升级膨胀为重量级锁,其他线程也因为是重量级锁而被阻塞,所以释放时还要唤醒被阻塞的线程。
5.3.3、升级总结
轻量级锁的设计集中在Lock Record属性markOop中存储的锁对象Mark Word和锁对象头存储的指向Lock Record的指针,这样设计的目的是因为,轻量级锁一定是有多个线程进行竞争的,锁对象在竞争时可能会发生状态的变化,但是Lock Record中存储的Mark Word肯定不会发生变化,这样通过对比Lock Record与锁对象的Mark Word,就可以判断锁对象是否被其他线程抢占过,如果有,就要在释放轻量级锁的过程中唤醒被阻塞的线程。
5.3.4、重入计数
轻量级锁和偏向锁的重入计数是很类似的,所以在这里详细讲述一下
在线程的Lock Record中储存着锁对象的Mark Lock和指向锁对象的owner,但这是首次分配时保存的。
之后再有的Lock Record中只保存指向锁对象的owner,displaced mark word为null
因此重入计数就由Lock Recod的数量来表示,需要解锁就删除一条,直到只剩下最初的那一条时,进行常规解锁操作。
5.4、重量级锁
5.4.1、基础信息
轻量级锁的锁标记为10,因此mark word的后两位为10。
重量级锁属于悲观锁,非公平锁(允许插队),线程进入阻塞状态。每一个java对象都有一个monitor,每一个线程都有一个监视器Monitor Record,当线程想要获取一个加锁资源时就必须获取到它的monitor,然后将所有权据为己有,直到线程运行完毕才会释放所有权,唤醒被阻塞的线程抢占该资源。准备抢占锁的线程会进入同步队列,没有抢占到资源的线程将进入阻塞状态。
5.4.2、流程分析
获取重量级锁之前,要先进行锁膨胀。锁膨胀需要创建一个对象ObjectMonitor,然后把ObjectMonitor对象的指针保存到锁对象的Wark Word之中。锁膨胀分为四种情况:
- 当前已经是重量级锁的状态,不需要继续膨胀,直接从锁对象的Mark Word中获取ObjectMonitor对象的指针返回。
- 如果有其他线程正在进行锁膨胀,那么就通过自旋的方式不断重试直到其他线程完成锁膨胀(即创建ObjectMonitor)
- 当前有其他线程获取了轻量级锁,那么当前线程会完成锁膨胀
- 如果当前是无锁状态,也就是说之前获取到的锁资源刚好把锁释放了,那么当前线程完成锁膨胀
以上过程都是通过自旋完成的,避免了线程竞争导致CAS失败的问题,因此轻量级锁没有自旋,在锁膨胀之后的自旋也是在重量级锁中实现的,只有重量级锁有自旋。
锁膨胀完成后,锁对象的Mark Word会保存指向ObjectMonitor的指针,重量级锁的竞争都在ObjectMonitor中完成,下面介绍一些ObjectMonitor中常见的字段
- owner:标识拥有该monitor的线程,初始时和锁被释放后都为null
- cxq (ConnectionList):竞争队列,没有获得锁的线程都会被放入这个队列中
- EntryList:候选者列表,当owner解锁时会将cxq队列中的线程移动到该队列中
- OnDeck:在将线程从cxq移动到EntryList时,会指定某个线程为Ready状态(即OnDeck),表明它可以竞争锁,如果竞争成功那么称为owner线程,如果失败则放回EntryList中
- WaitSet:因为调用wait()或wait(time)方法而被阻塞的线程会被放在该队列中
- count:monitor的计数器,数值加1表示当前对象的锁被一个线程获取,线程释放monitor对象时减1
- recursions:线程重入次数
接下来就是重量级锁的获取,很简单
- 首先,判断当前线程是否重入,如果是重入,则增加重入次数
- 然后,通过自旋完成锁的抢占,通过CAS来判断owner内的线程是否为null,如果为null证明锁已经被释放,可以进行获得到锁。如果不为null证明需要自旋重试,此处的自旋即自适应自旋。
- 最后,如果自旋失败,当前线程会构建一个ObjectWaiter节点,插入cxq队列的队首,再使用park()方法来阻塞该线程。
重量级锁的释放同样非常简单,当同步代码块执行完毕后,会触发重量级锁的释放
- 将ObjectMonitor中持有锁的对象owner置为null
- 从EntryList/cxq中唤醒头结点线程,这个线程被称为successor假定继承者
- 被唤醒的线程会加入到抢占之中,但是synchronized是非公平锁,能不能抢占到无法保证,如果未能成功抢占,则需要重回cxq之中等待
5.4.3、升级总结
park()方法是用来阻塞竞争队列线程的方法,需要注意的是,park()方法是需要系统调用完成的,用户态无法完成系统调用,因此会发生用户态到内核态之间的切换,重量级锁消耗性能的主要原因就是这个。
6、synchronized的降级
同步锁存在降级吗?存在的。
在全局安全点,执行清理任务的时候会触发尝试降级锁
- 恢复锁对象的mark word对象头
- 重置ObjectMonitor,然后将该ObjectMonitor放入全局空闲列表,等待后续使用。
7、synchronized的优化
jdk1.6中对锁的实现引入了大量的优化
- 锁粗化:将紧紧连接在一起的lock指令合成一个
- 锁消除:清除掉没有竞争资源的锁
- 增加轻量级锁和自旋
- 自适应自旋:从原本的10次自旋尝试变成了自适应,即上次等待时间长的将会缩短时间,上次时间短的可以放宽时间。