Java并发编程(5) —— synchronized关键字详解

文章详细介绍了Java中的synchronized关键字,包括其用法、特性、与volatile的区别,以及锁的升级过程,如无锁、偏向锁、轻量级锁到重量级锁的转变。同时提到了JVM对synchronized的优化,如锁粗化和锁消除,以提高并发性能。
摘要由CSDN通过智能技术生成

上一篇:Java并发编程(4) —— Java 内存模型(JMM)详解

在上一篇中我们提到了volatile关键字可通过插入内存屏障的方式来保证变量的可见性(每次使用都到主存中进行读取)和有序性(不允许指令重排序),但是volatile关键字不保证对变量复合操作的原子性,例如i++操作在jvm层面实际上是通过多条指令操作完成,而若用synchronized 关键字将操作代码块包起来,则同一时刻只有一个线程能进入进行操作,这就保证了原子性。

在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。

一、synchronized的使用方法

synchronized 块是 Java 提供的一种原子性内置锁,Java 中的每个对象都可以把它当作一个同步锁来使用。

synchronized关键字可以用来修饰实例方法、静态方法、代码块,表示对其进行加锁,当线程进入 synchronized 代码块前只有获取到相应的锁才能访问,否则自动进入自旋或阻塞状态(BLOCKED)等待锁被其他线程释放后竞争锁。

1、修饰实例方法 (锁当前对象实例)

给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁 。

synchronized void method() {
    //业务代码
}

2、修饰静态方法 (锁类对象)

给当前类加锁,进入同步代码前要获得 当前类class对象的锁。这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例。

synchronized static void method() {
    //业务代码
}

3、修饰代码块 (锁指定对象/类对象)

  • synchronized(object) 表示进入同步代码前要获得 给定对象的锁。
  • synchronized(类.class) 表示进入同步代码前要获得 给定 Class对象 的锁。
synchronized(this) {
    //业务代码
}

二、synchronized的特性

1. 可重入锁

持有锁的线程可直接进入此锁关联的任意其他代码。

2. 非公平锁

不是按照先来后到的原则来分配锁。

3. 不可中断锁

synchronized在锁竞争时是不可中断的,获取不到锁的线程会一直处于阻塞状态。而ReentrantLock获取锁失败可以被interrupt()进行中断操作。

三、synchronized相关问题

1. volatile和synchronized的区别是什么?

  • volatile 关键字用于修饰变量,可保证变量的可见性和有序性。
  • synchronized关键字用于修饰方法或代码块,可保证代码块的原子性以及代码块内变量的可见性,以及代码块外部和内部之间的有序性(代码块内部的有序性不保证,例如DCL单例指令重排问题)。

2. 占有锁的线程在什么情况下会释放锁?

  • 占有锁的线程执行完了该代码块,然后释放对锁的占有;
  • 占有锁线程执行发生异常,此时JVM会让线程自动释放锁;
  • 占有锁线程进入 WAITING 状态从而释放锁,例如在该线程中调用wait()方法等

四、synchronized的底层原理

对于synchronized同步代码块,编译后在代码块前后分别有一个monitorenter 和 monitorexit 指令,在JVM中当线程执行到monitorenter指令时尝试获取指定对象的锁,执行到monitorexit 指令则释放锁。

在这里插入图片描述

对于synchronized同步方法,编译后方法中有一个ACC_SYNCHRONIZED标识,在JVM中当线程执行到有此标识的方法时会隐式调用monitorenter和monitorexit。在执行同步方法前会调用monitorenter,在执行完同步方法后会调用monitorexit。最后都能达到加锁的效果。

在这里插入图片描述

五、synchronized的锁升级过程

早期synchronized实现的同步锁为重量级锁。但是重量级锁会造成线程阻塞排队,阻塞和唤醒线程会使CPU在用户态和核心态之间频繁切换,所以代价高、效率低。因此 Java6 对 synchronized 锁进行了优化,增加了轻量级锁和偏向锁。为了提高效率,不会一开始就使用重量级锁,JVM在内部会根据需要,按如下步骤进行锁的升级:

在这里插入图片描述

1. 无锁状态

初期锁对象刚创建时,还没有任何线程来竞争,对象的markword是上图的第一种情形,这偏向锁标识位是0,锁状态01,说明该对象处于无锁状态(无线程竞争它)。

2. 偏向锁

当有一个线程来竞争锁时,先用偏向锁,会在对象头的markword中记录线程threadID,并且线程退出synchronized块后偏向锁不会主动释放,因此之后此线程需要再次获取锁的时候,通过比较当前线程的 threadID 和 对象头中的threadID 发现一致,就不需要再做任何检查和切换直接进入,这种竞争不激烈的情况下,效率非常高。如上图第二种情形。

JDK 15中的偏向锁
偏向锁在单线程反复获取锁的场景下性能很高,但细想便知生产环境中高并发的场景下很难有这种场景。
而且对于偏向锁来说,在多线程竞争时的撤销操作十分复杂且带来了额外的性能消耗(需要等到safe point,并STW)。
JDK 15 之前,偏向锁默认是 开启的,从 JDK 15 开始,默认就是关闭的了,需要显式打开(-XX:+UseBiasedLocking)。

3. 轻量级锁

当需要获取对象的hashcode值时就会禁用偏向锁升级为轻量级锁,将hashcode值写入markword;或者当有第二个线程开始竞争这个锁对象,通过对比markword中记录线程threadID发现不一致,那么首先需要查看Java 对象头中记录的线程 1 是否存活(偏向锁不会主动释放锁),如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程 2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程 1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程 1,撤销偏向锁,升级为 轻量级锁,如果线程 1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。如上图第三种情形。

在这里插入图片描述

4. 重量级锁(monitor)

当轻量级锁等待获取锁的线程自旋到达一定次数,或者竞争的这个锁对象的线程更多,导致了更多的切换和等待,JVM会把该锁对象的锁升级为重量级锁。synchronized的重量级锁是基于在监视器(monitor)实现的,JVM中每个对象都可以关联一个ObjectMonitor监视器对象(C++实现),升级为重量级锁后对象的Mark Word再次发生变化,会指向对象关联的监视器对象(如上图第四种情形)。

ObjectMonitor的主要数据结构如下:

 ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,
    _recursions   = 0;      //线程重入次数
    _object       = NULL;   //指向对应的java对象
    _owner        = NULL;   //持有锁的线程
    _WaitSet      = NULL;   //等待队列:处于wait状态的线程会被加入到这个队列中
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;  //要竞争锁的线程会先被加入到这个队列中
    FreeNext      = NULL ;
    _EntryList    = NULL ;  //处于blocked阻塞状态的线程,会被加入到这个队列中
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
    _previous_owner_tid = 0;
  }

锁竞争机制:

  • 当线程要获取锁时,首先将其加入cxq队列的头部,获取失败被阻塞后则加入EntryList队列。
  • 当持有锁的线程释放锁时,会根据策略唤醒cxq或者EntryList队列中的线程来竞争锁。
  • 当线程调用wait()方法后会释放锁进入阻塞状态并加入waitSet等待队列。当调用notify方法后,会从waitSet中唤醒线程加入到cxq或者EntryList队列。

在这里插入图片描述

六、synchronized的其它优化

1. 锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小,只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

public void test() {
    for (int i = 0; i < 100; i++) {
        synchronized (object) {
            i++;
        }
    }
}

我们看以上方法,在for循环中,synchronized保证每个i++操作都是原子性的。但是以上的方法有个问题,就是在每次循环都会加锁,开销大,效率低。

虚拟机即时编译器(JIT)在运行时,会自动根据synchronized的影响范围进行锁粗化优化。

优化后代码:

public void test() {
    synchronized (object) {
        for (int i = 0; i < 100; i++) {
            i++;
        }
    }
}

2. 锁消除

消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java 虚拟机在 JIT 编译时通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,我们知道StringBuffer 是线程安全的,里面包含锁的存在,但是如果我们在函数内部使用 StringBuffer局部变量,那么代码会在 JIT 后会自动将锁消除。


参考:

  1. Java并发编程——Synchronized锁升级机制
  2. 由浅入深,逐步了解 Java 并发编程中的 Synchronized!
  3. java对象头 MarkWord
  4. Synchronized底层剖析
  5. synchronized底层原理:Monitor(管程/监视器)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值