关于Synchronized的理解

651 阅读7分钟

「这是我参与2022首次更文挑战的第2天,活动详情查看:2022首次更文挑战


synchronized 关键字解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在同一时间只能由一个线程访问。

使用方式

  • 修饰实例方法

针对当前对象实例加锁,执行代码块的前提是获取到当前对象实例

public synchronized void method1(){
    //method
}
  • 修饰静态方法

给当前类加锁,会作用于类的所有对象实例,进入同步代码前需要获取当前class的锁。

注意:静态成员不是实例对象,是类成员,如果当一个类中存在多个加锁的静态方法时,此时会发生互斥线程,会去争抢锁。

public synchronized static void method2(){
    //method
}
  • 修饰代码块

指定加锁对象,对给定对象/类加锁。 synchronized(this | Object) 表示进入同步代码块前需要获取指定的对象的锁。

synchronized(类.class) :表示进入同步代码块前要获取到当前class的锁。

public void method3(){
    synchronized (this){
        //method
    }
}

锁原理

  • 示例代码

先调用 javac 编译出class文件,然后再通过 javap -c -s -v -l xxx.class 查看相关字节码信息。

    public static void main(String[] args)  {
        LockDemoApplication lockDemoApplication = new LockDemoApplication();
        lockDemoApplication.method1();
        lockDemoApplication.method3();
    }
   //修饰方法
    public synchronized void method1(){
        //method
        System.out.println("java");
    }
    //修饰代码块
    public void method3(){
        synchronized (this){
            //method
            System.out.println("java");
        }
    }
  • synchronized 是如何修饰代码块的

synchronized 同步代码块的时候使用的是 monitorenter 指令和 monitorexit 指令(monitorenter 指令指向同步代码块开始的位置,monitorexit 指令指向同步代码块结束的位置)。当执行monitorenter指令的时候,线程就会试图去获取对象监视器的持有权。 在执行 monitorenter 指令时,会尝试去获取对象的锁,如果锁的计数器为0则表示锁可以被获取,获取后将锁计数器设为1也就是加1。

在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放,如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另一个线程释放为止。

第一个 monitorexit 指令时正常结束时执行的,第二个 monitorexit 指令是保证同步代码块抛出异常时能正确的释放锁而存在的。

image.png

  • synchronized修饰方法 synchronized 修饰的方法并没有 monitorentermonitorexit 指令,而是通过 ACC_SYNCHRONIZED 标识来指明该方法是一个同步方法,JVM通过该标识来辨别一个方法是否声明为同步方法。

image.png

**注意:**两者的本质都是对对象监视器持有权的获取

锁升级

jdk1.6以前, synchronized 是属于重量级锁,效率低下。在jdk1.6及以后,对 synchronized 进行了优化,从而有了锁升级的概念。

锁的级别从低到高依次是

  1. 无锁状态
  2. 偏向锁状态
  3. 轻量级锁状态
  4. 重量级锁状态 这几个状态会随着竞争情况逐渐升级,锁可以升级,但是不能降级

无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

无锁

没有对资源进行锁定(偏向锁标志位为“0”,锁标志位为“01”)。 JDK1.8的默认对象头是无锁的

偏向锁

初次执行到 synchronized 同步代码块的时候,锁对象变成偏向锁(修改对象头),字面意思就是“偏向于第一个获得它的线程”;执行完同步代码块后,线程并不会主动去释放锁,当第二次到达同步代码块时,线程会判断此时持有锁的线程是否是自己,如果是则直接执行同步代码块(因为之前没有释放锁,所以现在无需加锁),假如自始至终使用锁的线程只有一个,偏向锁机会是没有额外的开销的。

偏向锁的加锁

当一个线程访问同步块并获取锁时, 会在锁对象的对象头和栈帧中的锁记录里存储锁偏向的线程ID。以后该线程进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要检查一下对象头的 Mark Word 里是否存储着指向当前线程的偏向锁,如果是,表示线程已经获取到了锁,可以直接执行同步代码块,如果不是,则需要再去测试一下 Mark Word 中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用 CAS 竞争锁(轻量级锁);如果设置了,则尝试使用 CAS 将对象头的偏向锁指向当前线程。

偏向锁只需要在置换ThreadID的时候依赖一次 CAS

偏向锁的释放

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程活着,用户偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的 Mark Word 要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

image.png

关闭偏向锁

偏向锁在JDK1.6和JDK1.7里是默认启用的,但是它在程序启动几秒后才激活。

如何关闭延迟?通过JVM参数

-XX:-BiasedLockingStartupDelay=0

如何关闭偏向锁?通过JVM参数,关闭后会默认进入轻量级锁状态

-XX:-UseBiasedLocking=false

轻量级锁

当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞。

轻量级锁获取主要有两种情况:

  1. 关闭偏向锁功能
  2. 有多个线程竞争偏向锁导致偏向锁晋升为轻量级锁

轻量级锁加锁

当处于偏向锁状态下,存在第二个线程加入锁竞争(锁竞争:如果多个线程轮流获取锁,不存在阻塞的情况,则不存在锁竞争,只有当某线程尝试获取锁的时候,发现锁被占用了,此时就存在锁竞争的情况了。),此时偏向锁就会晋升为轻量级锁(自旋锁)。

线程在执行同步块之前, JVM会先在当前线程的栈帧中创建用户存储锁记录的空间, 并将对象头中的MarkWord复制到锁记录中,官方称为 Displaced Mark Word 。 然后线程尝试使用CAS将对象头中的MarkWord替换为指向锁记录的指针. 如果成功, 当前线程获得锁; 如果失败, 表示其它线程竞争锁, 当前线程便尝试使用自旋来获取锁, 之后再来的线程, 发现是轻量级锁, 就开始进行自旋。

轻量级锁解锁

轻量级锁解锁时, 会使用原子的CAS操作将当前线程的锁记录 Displaced Mark Word 替换回到对象头, 如果成功, 表示没有竞争发生; 如果失败, 表示当前锁存在竞争, 锁就会膨胀成重量级锁。

image.png

重量级锁

重量级锁是指当一个线程获取锁之后,其他等待获取该锁的线程都会处于等待状态。

如果锁竞争严重,某个达到最大自旋次数的线程,就会将锁晋升为重量级锁(通过CAS修改锁标识位为“10”),当后续尝试获取锁的线程发现是重量级锁,就会进入阻塞等待。

偏向锁通过对比Mark Word解决加锁问题,避免执行CAS操作。而轻量级锁是通过用CAS操作和自旋来解决加锁问题,避免线程阻塞和唤醒而影响性能。重量级锁是将除了拥有锁的线程以外的线程都阻塞。