面试官眼中的Synchronized

561 阅读10分钟

synchronized 是基于 Java 对象头和 Monitor 机制来实现的。

JDK1.5以后对synchronized做了优化,锁升级的过程不可逆,可能会跨级别

Java 对象头

一个对象在内存中包含三部分:对象头,实例数据和对齐填充。其中 Java 对象头包含两部分:

  • Class Metadata Address (类型指针)。存储类的元数据的指针。虚拟机通过这个指针找到它是哪个类的实例。

  • Mark Word(标记字段)。存出一些对象自身运行时的数据。包括哈希码,GC 分代年龄,锁状态标志等。

  • Monitor

    • Mark Word 有一个字段指向 monitor 对象。monitor 中记录了锁的持有线程,等待的线程队列等信息。前面说的每个对象都有一个锁和一个等待队列,就是在这里实现的。 monitor 对象由 C++ 实现。其中有三个关键字段:

      • _owner 记录当前持有锁的线程
      • _EntryList 是一个队列,记录所有阻塞等待锁的线程
      • _WaitSet 也是一个队列,记录调用 wait() 方法并还未被通知的线程。
    • Monitor的操作机制如下:

      • 多个线程竞争锁时,会先进入 EntryList 队列。竞争成功的线程被标记为 Owner。其他线程继续在此队列中阻塞等待。
      • 如果 Owner 线程调用 wait() 方法,则其释放对象锁并进入 WaitSet 中等待被唤醒。Owner 被置空,EntryList 中的线程再次竞争锁。
      • 如果 Owner 线程执行完了,便会释放锁,Owner 被置空,EntryList 中的线程再次竞争锁。

      线程尝试获取monitor的所有权,如果获取失败说明monitor被其他线程占用,则将线程加入到的同步队列中,等待其他线程释放monitor当其他线程释放monitor后,有可能刚好有线程来获取monitor的所有权,那么系统会将monitor的所有权给这个线程,而不会去唤醒同步队列的第一个节点去获取,所以synchronized是非公平锁。如果线程获取monitor成功则进入到monitor中,并且将其进入数+1

实例数据

  • 存放类的数据信息,父类的信息

对齐填充

  • 由于虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐

Tips:一个空对象占8个字节,因为对象填充的关系,不到8个字节对其填充会帮我们自动补齐。

同步方法和同步代码块

javap -v xxx.class去查看对应的字节码

JVM 对 synchronized 的处理

上面了解了 monitor 的机制,那虚拟机是如何将 synchronized 和 monitor 关联起来的呢?分两种情况:

  • 如果同步的是代码块(所括号里面的对象),编译时会直接在同步代码块前加上 monitorenter 指令,代码块后加上 monitorexit 指令。这称为显示同步。

    • 可以发现synchronized同步代码块是通过加monitorentermonitorexit指令实现的。 每个对象都有个**监视器锁(monitor) **,当monitor被占用的时候就代表对象处于锁定状态,而monitorenter指令的作用就是获取monitor的所有权,monitorexit的作用是释放monitor的所有权,这两者的工作流程如下:

    • monitorenter:如果monitor的进入数为0,则线程进入到monitor,然后将进入数设置为1,该线程称为monitor的所有者。

      1. 如果是线程已经拥有此monitor(即monitor进入数不为0),然后该线程又重新进入monitor,则将monitor的进入数+1,这个即为锁的重入
      2. 如果其他线程已经占用了monitor,则该线程进入到阻塞状态,知道monitor的进入数为0,该线程再去重新尝试获取monitor的所有权
    • monitorexit:执行该指令的线程必须是monitor的所有者,指令执行时,monitor进入数-1,如果-1后进入数为0,那么线程退出monitor,不再是这个monitor的所有者。这个时候其它阻塞的线程可以尝试获取monitor的所有权。

  • 如果同步的是方法(锁当前对象),虚拟机会为方法设置 ACC_SYNCHRONIZED 标志。调用的时候 JVM 根据这个标志判断是否是同步方法。 ACC_SYNCHRONIZED会去隐式调用monitorenter和monitorexit 指令,所以最终还是monitor对象的争夺。

    JVM就是根据这个标识来实现方法的同步。 当调用方法的时候,调用指令会检查方法是否有ACC_SYNCHRONIZED标识,有的话线程需要先获取monitor,获取成功才能继续执行方法,方法执行完毕之后,线程再释放monitor,同一个monitor同一时刻只能被一个线程拥有。

    这两个同步方式实际都是通过获取monitor和释放monitor来实现同步的,而monitor的实现依赖于底层操作系统的mutex互斥原语,而操作系统实现线程之间的切换的时候需要从用户态转到内核态,这个转成过程开销比较大。

如何保证有序性、可见性、原子性

  • 有序性:cpu会为了优化代码,对指令进行重排序(有数据依赖,无法进行重排序)单线程情况下,结果保证正确

  • 可见性:volatile,JMM内存模型

  • 原子性:只有一个线程能拿到锁

  • 可重入性:sync锁对象时有个计数器,如果已经获得过锁再次进入,计数器会加一,执行完对应代码后减一,计数器为零,锁就会自动释放

    • 可重入的好处,避免死锁
  • 不可中断性:一个线程获得锁之后,另一个线程处于阻塞或者等待,前一个不释放,后一个一直等待或阻塞,不可中断

Tips:Lock 的try Lock()方法是可以中断的

  • 设置超时时间
  • Lock.Interruptibly()放代码块中,调用intereupt()方法可中断

为什么Synchronized是非公平锁

和线程获取、释放monitor的过程有关

  • 线程尝试获取monitor的所有权,如果获取失败说明monitor被其他线程占用,则将线程加入到的同步队列中,等待其他线程释放monitor当其他线程释放monitor后,有可能刚好有线程来获取monitor的所有权,那么系统会将monitor的所有权给这个线程,而不会去唤醒同步队列的第一个节点去获取,所以synchronized是非公平锁。如果线程获取monitor成功则进入到monitor中,并且将其进入数+1

到这里我们也清楚了synchronized的语义底层是通过一个monitor的对象完成,其实waitnotiyfnotifyAll等方法也是依赖于monitor对象来完成的,这也就是为什么需要在同步方法或者同步代码块中调用的原因(需要先获取对象的锁,才能执行),否则会抛出java.lang.IllegalMonitorStateException的异常

1.6-锁升级(锁优化)

1.6之前synchronized是重量级锁,重量的本质是object monitor调用的过程,以及用户态和内核态的切换,大量消耗系统资源

用户态和内核态的切换

  • synchronized加锁
  • 异常事件
  • 外围设备的终端

JVM参数:是否使用偏向锁: -XX:+UseBiasedLocking

锁升级

  1. **无锁状态:**synchronized锁的锁的对象,开始为无锁状态,头部markword区域,锁状态标志位为001(默认值)

  2. **偏向锁状态:**线程1执行到同步代码块,虚拟机会使用CAS尝试修改状态 标志位,修改为偏向锁状态,将对象头MarkWord中线程ID指向线程1(把线程1的线程ID记录到markword区域的23bit位)

    **tips:**进入偏向锁状态,如果没有其他线程竞争,线程1后续再次访问同步代码块时,JVM不会再进行CAS加锁、解锁,直接运行同步代码块,直到线程执行完毕后,JVM会释放偏向锁,将MarkWord的标志位恢复成初始状态。

  3. **轻量级锁状态:**线程1持有偏向锁期间,线程2访问同步代码块,尝试获取锁,JVM会检查线程1的状态,线程1还持有锁,JVM会把锁升级为轻量级锁,线程2会进行CAS自旋,尝试获取轻量级锁。

  4. **重量级锁状态:**进入轻量级锁状态后,线程2会继续CAS尝试获取锁,这时候synchronized并不会立即进入重量级锁,而是等待线程2自旋达到一定次数后(10次),才会升级为重量级锁,次数可通过JVM参数设置。

    **tips:**在 JDK1.4.2 中,自选的次数可以通过参数来控制。 JDK 1.6又引入了自适应的自旋锁,不再通过次数来限制,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

  • 开启自旋锁: -XX:+UseSpinning

  • 自旋锁等待次数:-XX:PreBlockSpin

    线程1执行完同步代码块,JVM尝试释放锁,在释放锁时,发现是重量级锁,说明其他竞争线程已经进入阻塞状态,JVM在释放锁之后,还会唤醒其他进入阻塞状态的线程。

  • **锁消除:**虚拟机在运行时,如果发现一段被锁住的代码中不可能存在共享数据,就会将这个锁清除。

  • **锁粗化:**当虚拟机检测到有一串零碎的操作都对同一个对象加锁时,会把锁扩展到整个操作序列外部。如 StringBuffer 的 append 操作。

和lock的区别

  • synchronized是关键字,底层通过monitor对象来完成,其实wait/notify等方法也依赖于monitor对象只有在同步块或方法中才能调wait/notify等方法,JVM层面的。Lock是一个接口,是JDK层面的,有丰富的Api

  • synchronized会自动释放锁,而lock必须手动释放锁,lock,unlock配合try finally完成

  • 通过lock可以知道线程有没有拿到锁,而synchronized不能

  • synchronized可以锁住代码和方法块,lock只能锁住代码块

  • lock可以使用读锁提高多线程读效率(不讲,reeawriteLock)

  • synchronized是非公平锁,ReentrantLock可以控制是否公平锁(默认非公平锁)

  • 使用方法synchronized不可中断,除非抛出异常或者正常运行完成,ReentrantLock可以中断,

    1.设置超时方法tryLock(long time, TimeUnit unit)

    2.lockInterruptibly()放代码块中,调用interrupt()方法可中断

  • 锁绑定多个条件Condition,ReentrantLock可以分组唤醒线程,而不是像synchronized那样要么全部唤醒,要么随机唤醒一个

synchronized的缺陷

  • 程序执行完同步代码块会释放代码块。
  • 程序在执行同步代码块是出现异常,JVM会自动释放锁去处理异常。
  • 如果获取锁的线程需要等待I/O或者调用了sleep()方法被阻塞了,但仍持有锁,其他线程只能干巴巴的等着,这样就会很影响程序效率。
  • 因此就需要一种机制,可以不让等待的线程已知等待下去,比如值等待一段时间或响应中断,Lock锁就可以办到。
  • synchronized原始采用的是CPU悲观锁机制,即线程获得的是独占锁。独占锁意味着其他线程只能依靠阻塞来等待线程释放锁。而在CPU转换线程阻塞时会引起线程上下文切换,当有很多线程竞争锁的时候,会引起CPU频繁的上下文切换导致效率很低,Lock用的是乐观锁方式,CAS操作。