面试中常问的Synchronized关键字

478 阅读7分钟

最近在看多线程,面试中synchronized关键字可谓是一个热点,看了视频看了书看了博客,今天就来解决这么一些问题。
为什么设计synchronized关键字?synchronized的底层原理是什么?锁的膨胀升级是怎么一回事?

Ⅰ:为什么设计Synchronized?

模拟web环境下出现线程安全

@Controller
public class OrderController {
    @Autowired
    OrderService service;

    @RequestMapping("/desOrder")
    public void desOrder(){
       service.desStock();
    }
}
@Service
public class OrderService {
    //模拟,定死库存为5
    private int stock = 5;

    //下单减少库存
    public void desStock() {
        if (stock > 0) {
            try {
                //人为模拟因为有的用户网速过慢
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("下单成功,当前剩余产品数:" + stock--);
        }else{
            System.out.println("产品已经无库存了...");
        }
    }
}

利用多线程压测工具JMeter同时开20个线程去访问,关于JMeter的下载与使用教程:www.jianshu.com/p/0e4daecc8…

虽然在单线程情况下并发访问出现超卖具有偶发性,但我们模拟了下因为别的原因卡了0.1S,导致的运行结果如下,可见出现了较大的隐患。

synchronized的设计就是为了防止出现上述多线程并发访问一个共享资源并进行写操作而导致的线程不安全现象。

上锁的方式有多种,为什么选择synchronized?

synchronized的使用方式大家都知道,同步代码块、同步方法利用同步上锁来解决问题。JDK1.5之前synchronized关键字还比较笨重,到1.6之后就对它进行了升级优化引入了偏向锁和轻量级锁,到目前跟其他锁机制性能基本持平了。我们需要去了解它如何优化的,面试也常常这个。

Ⅱ:Monitor对象

引入Monitor

修改orderService的代码,通过同步代码块来确保线程安全
    //下单减少库存
    public void desStock() {
        synchronized (obj) {
            if (stock > 0) {
                try {
                    //人为模拟因为有的用户网速过慢
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("下单成功,当前剩余产品数:" + stock--);
            } else {
                System.out.println("产品已经无库存了...");
            }
        }
    }

在查看其执行后的字节码文件

通过查看字节码文件可知加了同步代码块后,程序每次都要获取锁、执行业务逻辑、释放锁

montiorenter 进入并获取对象监视器
monitorexit 释放并退出对象监视器

可见synchronized关键字底层依赖内部对象Monitor来实现,那么Monitor对象到底是什么呢?

Monitor介绍

在Java的设计中,每个对象创建后都有一个 monitor 与之关联,当一个 monitor 被某个线程持有后,它便处于锁定状态,可以把它简单理解未一个监视器。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)。
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 ;
 }

Ⅲ:Java对象头

synchronized用的锁是存在Java对象头里面的,我们需要先来了解下Jjava对象在内存中的表现形式。

java对象在内存中的表现形式:

HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

  • Mark Word :用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁的信息等
  • Class Metadata Address:引用指向,指向哪个类
  • padding:对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。HotSpot虚拟机中要求对象大小必须是8字节的整数倍,若不够的话对齐填充来凑。

对象头的MarkWord

无锁状态的对象头MarkWord

在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化(32位)

Ⅳ:锁的膨胀升级

在第二部分Monitor那里提到了synchronized关键字依赖于montiorenter获取锁和monitorexit释放锁,也提到了monitor是C++实现的并放入了JVM虚拟机中。而monitor对象的实现是基于底层操作系统的Mutex Lock(互斥量)实现的,基于互斥量实现的同步需要经历一个用户态到核心态的转变,这个开销较大,频繁转换会影响到程序性能,所以JDK1.6之前synchronized是一个重量级锁,而1.6对它的优化就是引入了偏向锁、轻量级锁。

偏向锁

HotSpot的作者研究发现多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
  • 获取偏向锁
    当一个线程(假设为A)访问同步代码块并获取锁时,会在该锁的对象头和栈帧的锁记录里存储偏向线程的ID,下次A线程访问时只需对比线程ID即可获取,此时B线程来获取锁对象,对比偏向的线程ID就会获取锁对象失败,失败后先去判断该锁对象的MarkWord的偏向锁标志位是否是1,是1说明A线程已经执行完毕释放锁了,处于可偏向状态,则尝试使用CAS操作修改偏向线程ID指向B线程。若没有设置,则处于不可偏向状态,用CAS尝试去竞争
  • 释放偏向锁
    偏向锁不会自己主动释放,只有等到竞争出现才会释放。偏向锁的撤销需要等待全局安全点(该时间点上没有正在执行的字节码)。它首先检查这个持有偏向锁的线程是否还在活动,没有的话就将该锁对象的对象头设置为无锁状态。如果仍然在活动,则撤销偏向锁升级为轻量级锁
  • 关闭偏向锁 偏向锁默认是开启的,如果你确定应用程序里面的锁通常情况下都处于竞争状态,可以通过JVM参数关闭偏向锁,那么程序会默认进入轻量级锁。

    -XX:-UseBiasedLocking=false

轻量级锁

引入轻量级锁的主要目的是在多没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
  • 轻量级锁加锁
    线程在执行同步代码块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,然后线程接着去竞争,竞争失败就经历一定次数的自旋(做空循环)来获取锁。
  • 轻量级锁解锁
    解锁时,会使用原子的CAS操作将Displaced Mark Word替换回对象头,如果成功则表示没有竞争发生。如果失败,锁就是膨胀成重量级锁。

重量级锁

重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。当线程升级为重量级锁后包括后面来竞争的线程就都会处于阻塞状态,只有等持有锁的线程执行完毕来唤醒它们,适用于同步代码块执行时间比较长的场景。

锁的优缺点对比

Ⅴ:小结

学习了这么多要是秋招碰到面试官问我synchronized关键字的时候,就可以从synchronized的三种用法、底层实现monitor、为何要优化、举两个线程A、B竞争而导致的锁的升级过程、当然了还有很多展开问的,比如为何用户态转为内核态消耗资源大?CAS操作又是什么?

参考资料

《java并发编程的艺术》
zhuanlan.zhihu.com/p/50984945
juejin.cn/post/684490…
www.bilibili.com/video/av575…
juejin.cn/post/684490…