作为Java工程师你真的理解synchronized吗

造成线程安全问题的诱因主要有两点:

  1. 存在共享数据(临界区资源)
  2. 存在多个线程对临界区资源的读写操作

因此,为了保证临界区数据的安全,引入了互斥锁的概念,即一个共享数据同时只能被一个线程访问,其他线程需要等待(阻塞),直至当前线程处理完毕释放该锁。synchronized就保证了同一时刻只有一个线程对方法或者代码块的共享数据的操作。而且,synchronized由于他的互斥性间接的保证了一个线程对共享变量操作的变化被其他线程看到

一句话介绍synchronized:

synchronized是一个Java关键字,可以用来修饰方法和代码块,主要的作用就是保证同一时刻只能有一个线程执行临界区中的代码,以此达到线程安全的效果

一、synchronized修饰方法和代码块

synchronized是Java语言内置的锁,可以用于代码块和方法(静态方法和普通成员方法)

1、synchronized代码块
public class SynchronizedTest{
    
    private Object lock=new Object();
    
    public void fun(){
        synchronized(lock){
             //TODO
        }
    }
    
}

编译上面的代码,然后反编译.calss文件:javap -verbose SynchronizedTest,得到如下结果:

从反编译的结果可以看到:同步代码块在字节码层面通过monitorenter和monitorex两条字节码指令实现的

  • 当进入方法执行到monitorenter指令时,当前线程会尝试获取Monitor的所有权,成为这个Monitor的Owner,获取成功后继续执行临界区中的代码,执行结果会执行monitorexit指令,表示释放锁;如果获取锁失败那就会进入阻塞队列中等待;
  • 如果当前线程已经是此Monitor的Owner了,再次执行到monitorenter那就直接进入,并将进入次数+1
  • 同理,当执行完monitorexit,对应的进入数就-1,直到为0,才可以被其他线程持有
2、synchronized成员方法
public class SynchronizedTest{
    public synchronized void fun(){
        //TODO
    }
}

//成员方法等价于
public class SynchronizedTest{
    public void fun(){
        synchronized(this){
            //TODO
        }
    }
}

synchronized修饰成员方法,实际上是以当前对象为锁,进入同步代码前要获得当前对象实例的锁。因此并不存在给方法上锁的说法。

3、synchronized静态方法
public class SynchronizedTest{
    public synchronized static void fun(){
        //do something
    }
}

//静态成员方法等价于
public class SynchronizedTest{
    public static void fun(){
        synchronized(SynchronizedTest.class){
            //TODO
        }
    }
}

由于静态方法是属于类的,因此当我们用synchronized修饰静态方法时,实际上是以这个类的Class对象为锁,又由于Class的相关数据存储在永久带 PermGen(jdk1.8 则是 metaspace),永久代是全局共享的,因此静态方法锁相当于类的一个全局锁 。

方法的同步:在方法常量表中记录一个ACC_SYNCHRONIZED访问标记,调用指令会检查方法的常量表中是否设置了ACC_SYNCHORINZED标记 ,如果设置了这个标志,执行线程就需要先获取Monitor然后才能执行方法,最后方法执行完毕释放Monitor。 在方法执行期间,执行线程持有了该Monitor之后,其他线程就无法再获取了。如果在执行同步方法期间发生了异常,并且方法内部无法处理此异常,那么同步方法将会在抛出异常后自动释放Monitor。

二、Monitor概念

Monitor,意为监视器管程。monitor 的重要特点是,同一个时刻,只有一个 进程/线程 能进入 monitor 中定义的临界区,这使得 monitor 能够达到互斥的效果。但仅仅有互斥的作用是不够的,无法进入 monitor 临界区的 进程/线程,它们应该被阻塞,并且在必要的时候会被唤醒。显然,monitor 作为一个同步工具,也应该提供这样的管理 进程/线程 状态的机制。

在JVM中,每一个对象头都关联着Monitor(由操作系统提供),每一个监视器和一个对象引用相关联,为了实现监视器的互斥功能,每个对象都关联着一把锁(有时候又叫“互斥量,mutex”,信号量)。

一旦方法或者代码块被synchronized修饰,那么这个部分就放入了监视器的监视区域,确保一次只能有一个线程执行该部分的代码,线程在获取锁之前不允许执行该部分的代码。

这里多次提到Monitor,并且网上很多文章在提到synchornized的底层原理的时候也就截止到Monitor了,大家是不是以为Monitor就是个虚无的概念性的东东,其实不是,Hotsopt实现的Monitor是基于C++实现的一个叫ObjectMonitor.hpp文件中定义着。感兴趣的同学可以在Hotspot源码的\src\share\vm\runtime路劲下找到这个文件,可以去看看。下图是他的构造方法的样子:

把上面代码中的核心组件抽象一下我们可以看到如下示意图:

说明:上图中Thread-2就是Monitor的拥有者,Thread-1,Thread-3获取Monitor失败被阻塞在阻塞队列中
1、synchronized实现的核心组件
  • WaitSet:调用 wait 方法被阻塞的线程被放置在这里;
  • Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;
  • EntryList:竞争队列中那些有资格成为候选资源的线程被移动到 Entry List 中;
  • OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为 OnDeck
  • Owner:当前已经获取到所资源的线程被称为 Owner
  • !Owner:当前释放锁的线程。
2、synchronized的实现原理

(1)JVM 每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList 会被大量的并发线程进行 CAS 访问,为了降低对尾部元素的竞争,JVM 会将一部分线程移动到 EntryList 中作为候选竞争线程。
(2) Owner 线程会在 unlock 时,将 Contention List 中的部分线程迁移到 EntryList 中,并指定EntryList 中的某个线程为 OnDeck 线程(一般是最先进去的那个线程)。
(3)Owner 线程并不直接把锁传递给 OnDeck 线程,而是把锁竞争的权利交给 OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM 中,也把这种选择行为称之为“竞争切换”。
(4) OnDeck 线程获取到锁资源后会变为 Owner 线程,而没有得到锁资源的仍然停留在 EntryList中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻通过notify或者 notifyAll 唤醒,会重新进去 EntryList 中。
(5)处于 ContentionList、EntryList、WaitSet 中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux 内核下采用 pthread_mutex_lock 内核函数实现的)。
(6) Synchronized 是非公平锁。 synchronized 在线程进入 ContentionList 时,等待的线程会先尝试自旋获取锁,如果获取不到就进入 ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占 OnDeck 线程的锁资源。
(7) 每个对象都有个 monitor 对象,加锁就是在竞争 monitor 对象,代码块加锁是在前后分别加上 monitorenter 和 monitorexit 指令来实现的,方法加锁是通过一个ACC_SYNCHRONIZED标记位来判断的。但是无论是同步代码块,还是同步方法,在汇编指令层面都是通过lock cmpxchg这条汇编指令实现,在操作系统层面是通过mutex lock指令实现的。
(8)jdk1.6以前的synchronized是一个重量级操作,需要通过系统调用实现,性能是低效的,有可能给线程加锁消耗的时间比有用操作消耗的时间更多。
(9)jdk1.6及以后,官方对synchronized 进行了很多的优化,有适应自旋、锁消除、锁粗化、轻量级锁及偏向锁等,效率有了本质上的提高。在之后推出的 jdk1.7 与 1.8 中,均对该关键字的实现机理做了优化。引入了偏向锁和轻量级锁。都是在对象头中做标记位,少量的竞争不在需要系统调用来支持,紧在用户态就可以实现互斥性。
(10) 锁可以从偏向锁升级到轻量级锁,再升级到重量级锁。这种升级过程叫做锁膨胀
(11) jdk1.6 中默认开启了偏向锁和轻量级锁,可以通过JVM参数:-XX:-UseBiasedLocking 来禁用偏向锁。

三、Java虚拟机对synchronized的优化

jdk1.6之前,synchronized是一个重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的互斥锁来实现的,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。庆幸的是在Java 6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了,Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁,接下来我们将简单了解一下Java官方在JVM层面对synchronized锁的优化。

jdk1.6以后Java的锁有4种状态:无锁状态、偏向锁、轻量级锁(自旋锁)和重量级锁(如下图所示)。锁可以从偏向锁升级到轻量级锁,再升级到重量级锁,锁可以升级,但是锁不能降级,这种策略是为了提高获得锁和释放锁的效率

可以看到,在Hotspot的实现中,通过对象头后两位可以区分各种锁的状态(偏向锁需要后三位):

  • 001 表示无锁状态
  • 101 表示偏向锁状态
  • 00 表示轻量级锁状态
  • 10 表示重量级锁
synchronized锁膨胀过程

(1)无锁状态

刚new出来的对象就处于无锁状态。此时一个对象的对象头的Mark Word中主要存储的是对象的hashcode,只不过在主动调用hashcode()方法之前默认值都是0,代用之后才会存储真正的hashCode值、对象的分代年龄、最后三位001就可以标志这是一个没有锁的对象。

(2)偏向锁

偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,没有其他线程来获取锁,则持有偏向锁的线程永远不需要在进行同步。偏向锁可以提高带有同步但无竞争的程序的性能。

偏向锁的获取

当一个线程第一次获取偏向锁的时候,会使用CAS操作把获取到偏向锁的线程ID记录到在锁对象对象头的Mark Word,如果CAS操作执行成功,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁。只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

偏向锁的撤销

持有偏向锁的线程只有在竞争出现才会释放锁。当其他线程尝试竞争偏向锁时,当前线程到达全局安全点后(没有正在执行的代码),它会查看锁对象头中记录的线程是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程,撤销偏向锁,升级为轻量级锁,如果线程1不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。

偏向锁在JDK1.6之后是默认开启的,但是它有一个延迟,会在程序启动后几秒后才会激活,也可以通过JVM参数关闭延迟

-XX:BiasedLockingStartupDelay=0

当程序中的锁竞争是很可能发生的事情的时候可以直接使用JV命名关闭偏向锁

-XX:UseBiasedLocking=false

(3)轻量级锁

轻量级锁能够带来同步程序的性能的依据是:“对于大部分的锁,在整个同步周期类都是不存在竞争的”,这是一个经验数据。即:如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量带来对开销,但是如果存在竞争,除了互斥量的开销外,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比重量级锁更慢。

加锁

当锁升级为轻量级锁之后,会在该线程的桟帧中产生创建一块锁记录空间,并把对象头中的Mark Word复制到锁记录中,被称为Displaced Mark Word。然后线程尝试使用CAS操作将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,随后将锁标志设为00;如果CAS操作失败,表示其他线程竞争锁,当前线程将进入到自旋获取锁的过程。自旋是一个耗费处理器资源的行为,因此当自旋一定次数后,锁就会升级为重量级锁,当前线程就会被阻塞,自旋默认的次数是10次,可以通过JVM参数-X:preBlockSpin来修改。

在jdk1.6中引入了自适应的自旋锁。自适应的锁的自旋时间不是固定的,他会根据上一次在同一锁上的自旋时间以及拥有者的状态来决定。如果在同一个自旋锁对象上,自旋等待成功获取了锁并且正在运行,那么JVM就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续更长时间。如果对于某个锁,自旋成功很少获取到锁,那么JVM就会省略自旋过程,以避免浪费处理器资源。

解锁

轻量级解锁时,会使用CAS将锁对象当前的Mark Word和线程中复制的Displaced Mark Word替换,如果成功,整个同步过程就结束了。如果失败,表示又其他线程尝试获取过该锁,那就会在释放锁的同时唤醒被挂起的线程。

(4)重量级锁

当多个线程请求获取轻量级锁的时候,轻量级锁就会膨胀为重量级锁,重量级锁就是指当一个线程获得锁之后,其余等待这个锁的线程都将进入到阻塞状态。重量级锁是操作系统层面的锁,此时线程的调度都将由操作系统负责,因此这就会引起频繁的上下文切换,导致线程被频繁的唤醒和挂起,使得程序性能下降。重量级锁的底层实现在JVM层面是通过对象内部的监视器(monitor)实现的。

各种锁的比较

四、锁降级、锁消除、锁粗化

1、锁降级(不重要)

锁降级在某些特定情况下回发生,就是在GC的时候会发生,但是此时锁降级也就没有意义了,因此锁降级可以认为是不存在的。

2、锁消除 lock eliminate

**锁消除是指虚拟机在即时编译器运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行削除。**锁削除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行。

public void add(){
    StringBuffer sb=new StringBuffer();
    sb.append("heello").append("java");
}
3、 锁粗化 lock coarsening

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

//锁粗化之前
public void fun(){
    //task1
    for(int i=0;i<100li++){
        synchrnoized(this){
            //task2
        }
    }
}

//锁粗化优化后
public void fun(){
    //task1
    synchronized(this){
      for(int i=0;i<100li++){
            //task2
      }
    }
}

对于这种情况,JVM就会优化,把锁的范围扩大,这就是锁优化。

五、synchronized可重入锁实现底层原理

1、重入的概念?

可重入就是说若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错”,则称其为可重入(reentrant或re-entrant)的。即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的

通俗来说:当线程请求一个由其它线程持有的对象锁时,该线程会阻塞,而当线程请求由自己持有的对象锁时,如果该锁是重入锁,请求就会成功,否则阻塞。

synchronized拥有强制原子性的内部锁机制,是一个可重入锁。因此,在一个线程使用synchronized方法时调用该对象另一个synchronized方法,即一个线程得到一个对象锁后再次请求该对象锁,是永远可以拿到锁的

2、synchronized实现可重入锁的原理或机制

Java中每一个对象锁中都会和一个monitor关联,monitor中有一个计数器就是专门用来记录线程的重入次数的,当此计数器为0时表示该锁没有被任何线程持有,那么任何线程都可能获得该锁而执行相应的临界区代码;当某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为 1;此时其它线程请求该锁,则必须等待;而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增+1;当线程退出同步代码块时,计数器会递减-1,如果计数器为 0,则表示当前线程释放了该锁。

留言区

还能输入500个字符