前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >java并发编程实战(2) 线程同步synchronized

java并发编程实战(2) 线程同步synchronized

作者头像
黄规速
发布2022-04-14 15:21:03
4150
发布2022-04-14 15:21:03
举报
文章被收录于专栏:架构师成长之路

一、synchronized概述

1、synchronized特性

1.1、原子性:

synchronized保证语句块内操作是原子的,所谓原子性就是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。被synchronized修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放,这中间的过程无法被中断。synchronized和volatile最大的区别就在于原子性,volatile不具备原子性。

1.2、内存可见性:

可见性是指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的。

synchronized和volatile都具有可见性,其中synchronized对一个类或对象加锁时,一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到主存当中,保证资源变量的可见性,如果某个线程占用了该锁,其他线程就必须在锁池中等待锁的释放。

而volatile的实现类似,被volatile修饰的变量,每当值需要修改时都会立即更新主存,主存是共享的,所有线程可见,所以确保了其他线程读取到的变量永远是最新值,保证可见性。

线程解锁前,必须把共享变量的最新值刷新到主内存。 线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值(加锁和解锁是同一把锁

1.1.3 有序性

有序性值程序执行的顺序按照代码先后执行。

synchronized和volatile都具有有序性,Java允许编译器和处理器对指令进行重排,但是指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性。synchronized保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。

有序性是指令需守happens-before规则的基础上进行重排。

1.1.4 可重入性

synchronized和ReentrantLock都是可重入锁。当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁。通俗一点讲就是说一个线程拥有了锁仍然还可以重复申请锁。

synchronized 关键字和 volatile 关键字是两个互补的存在,而不是对立的存在! synchronized本质上是一种阻塞锁;而volatile则是使用了内存屏障来实现的; volatile 关键字能保证数据的可见性,但不能保证数据的原子性。synchronized 关键字两者都能保证。 volatile 关键字主要用于解决变量在多个线程之间的可见性,而 synchronized 关键字解决的是多个线程之间访问资源的同步性。

2、synchronized的用法和作用域

1)、synchronized修饰类方法,对前实例对象(this)加锁。 2)、synchronized修饰静态方法,对当前类的Class对象加锁。 3)、synchronized修饰代码块,对synchronized括号内的对象加锁

1)、synchronized修饰类方法

, 对当前实例对象(this)加锁,synchronized作用于实例方法 int i=0; public synchronized void increase(){ i++; }

1)synchronized (this) 虽然锁住了当前对象,但是当前对象的其他非 synchronized的方法是可以继续执行。 即当一个线程访问对象的一个 synchronized(this) 同步代码块时,另一个线程仍然可以访问该对象中的非 synchronized(this) 同步代码块。 2)其他线程对该对象中所有其它synchronized(this)同步代码块的访问也将被阻塞。 2)synchronized (this) 锁住当前对象,自然使用同一个对象再次访问临界区的时候就会阻塞。但是换一个对象访问这个临界区就不会阻塞

2)synchronized修饰静态方法

由于静态成员不专属于任何一个实例对象,是类成员,因此通过class对象锁可以控制静态成员的并发操作。

public static synchronized void increase(){ i++; }

这种锁住静态方法的方式其实就是锁住类,即锁住全部的对象,对象之间互斥。当多个对象并发执行此方法时,需要排队。 同理,执行非synchronized 代码块时不会阻塞。

3)、synchronized同步代码块

对synchronized括号内instance的对象加锁

synchronized(instance){ for(int j=0;j<1000000;j++){ i++; } }

synchronized 不能继承,父类的 synchronized 方法被子类重写后默认不是 synchronized 的,必须显示指定 接口的方法不能加 synchronized 关键字 构造方法不能加 synchronized 关键字,首先加了编译器会报错,其次 new 对象的过程采用 CAS 等方式保证了安全性,无需再次同步

二、synchronized代码块底层原理

synchronized有两种形式上锁,一个是对方法上锁,一个是构造同步代码块。他们的底层实现本质都一样:在进入同步代码之前先获取锁,获取到锁之后锁的计数器+1,同步代码执行完锁的计数器-1,如果获取失败就阻塞式等待锁的释放。只是他们在同步块识别方式上有所不一样,从class字节码文件可以表现出来,一个是通过方法flags标志,一个是monitorenter和monitorexit指令操作。

2.1、同步代码块:

代码语言:javascript
复制
package com.demo.test.testapp;

public class SynchronizedDemo {
    public synchronized void synchronizedMethod() {
        System.out.println("synchronizedMethod star");

    }
    public void synchronizedBlock() {
        synchronized(this) {
            System.out.println("synchronizedBlock start");
        }
    }
}

javac SynchronizedDemo.java编译后通过 javap -c SynchronizedDemo.class查看class字节码文件:

从反编译的同步代码块可以看到同步块:Java源代码被javac编译成bytecode的时候,会在同步块的入口位置和退出位置分别插入monitorenter和monitorexit字节码指令,即是由monitorenter指令进入,然后monitorexit释放锁。

2.2 同步块的Synchronized的底层实现原理:

是通过一个monitor监视器锁对象来完成,每一个对象都有且仅有一个与之对应的monitor对象。当monitor被占用时就会处于锁定状态。在执行monitorenter之前需要尝试获取monitor锁,如果这个对象没有被锁定,或者当前线程已经拥有了这个对象的锁,那么就把锁的计数器加1。当执行monitorexit指令时,monitor锁的计数器也会减1。当获取monitor锁失败时会被阻塞,一直等待锁被释放。

具体过程:

当线程执行monitorenter指令时尝试获取monitor的所有权,过程如下: 1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。 2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1. 3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

当线程执行monitorexit指令时:

1,执行monitorexit的线程必须是objectref所对应的monitor的所有者。 2,指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

1)为什么会有两个monitorexit呢?其实第二个monitorexit是来处理异常的,仔细看反编译的字节码,正常情况下第一个monitorexit之后会执行goto指令,而该指令转向的就是23行的return,也就是说正常情况下只会执行第一个monitorexit释放锁,然后返回。为了保证抛异常的情况下也能释放锁,所以javac为同步代码块添加了一个隐式的try-finally,在finally中会调用monitorexit命令释放锁,即第二个monitorexit。 2)wait/notify为什么必须在同步块使用?wait/notify等方法也是依赖monitor对象,因此必须在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常。

2.3、同步方法

代码语言:javascript
复制
public class SynchronizedDemo {
    public synchronized void synchronizedMethod() {
        System.out.println("synchronizedMethod star");

    }
}

编译后, 使用javac -v -c SynchronizedDemo.class

对于同步方法,JVM采用ACC_SYNCHRONIZED标记符来实现同步。

方法的同步并没有通过指令monitorenter和monitorexit来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据ACC_SYNCHRONIZED标示符来实现方法的同步的:

1)当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设。

2)如果设置 ACC_SYNCHRONIZED 访问标志,执行线程将先获取monitor对象,获取成功之后才能执行方法体。

3)方法执行完后再释放monitor对象。

4)在方法执行期间,其他任何线程都无法再获得同一个monitor对象。

其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。

2.3、Synchronized总结

Synchronized是通过一个monitor监视器锁对象来完成,每一个对象都有且仅有一个与之对应的monitor对象。在进入同步代码之前先获取锁,获取到锁之后锁的计数器+1,同步代码执行完锁的计数器-1,如果获取失败就阻塞式等待锁的释放。

Synchronized是Java并发编程中最常用的用于保证线程安全的方式,其使用相对也比较简单。但是如果能够深入了解其原理,对监视器锁等底层知识有所了解,一方面可以帮助我们正确的使用Synchronized关键字,另一方面也能够帮助我们更好的理解并发编程机制,有助我们在不同的情况下选择更优的并发策略来完成任务。对平时遇到的各种并发问题,也能够从容的应对。

在JDK 1.6中对锁的实现引入了大量的优化,了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。如锁消除(Lock Elimination)、锁粗化(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销。

三、JIT编译锁的优化

1、JIT(just in time)即时编译技术

介绍JIT的目的是为了说明锁消除和锁粗化使用的技术。

通常javac将程序源码编译,转换成java字节码,JVM通过解释字节码将其翻译成相应的机器指令,逐条读入,逐条解释翻译。非常显然,经过解释运行,其运行速度必定会比可运行的二进制字节码程序慢。为了提高运行速度,引入了JIT技术。

JIT是just in time,即时编译技术。使用该技术,可以加速java程序的运行速度。

工作原理 当JIT编译启用时(默认是启用的),JVM读入.class文件解释后,将其发给JIT编译器。JIT编译器将字节码编译成本机机器代码。 在执行时JIT会把翻译过的机器码保存起来,已备下次使用,因此从理论上来说,採用该JIT技术能够,能够接近曾经纯编译技术。

2、锁粗化(Lock Coarsening):

定义:JIT编译将相邻同一个锁对象的同步代码块合并为更大同步块

JIT编译器在执行动态编译时,若发现前后相邻的synchronized块使用的是同一个锁对象,那么它就会把这几个synchronized块给合并为一个较大的同步块,这样做的好处在于线程在执行这些代码时,就无需频繁申请与释放锁了,从而达到申请与释放锁一次,就可以执行完全部的同步代码块,从而提升了性能。

代码语言:javascript
复制
public class SynchronizedCoarsening {
    private Object object = new Object();
    public void method() {
        synchronized (object) {
            System.out.println("hello world");
        }
        synchronized (object) {
            System.out.println("welcome");
        }
        synchronized (object) {
            System.out.println("person");
        }
    }
}

3、锁消除(Lock Elimination)

定义:JIT编译器借助逃逸分析(Escape Analysis)的技术来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程,可以消除锁的使用。

JIT编译器可以在动态编译同步代码时,使用一种叫做逃逸分析的技术,通过该项技术判别程序中所使用的锁对象是否只被一个线程所使用,而没有散布到其他线程当中;如果情况就是这样的话,那么JIT编译器在编译这个同步代码时就不会生成synchronized关键字所标识的锁的申请与释放机器码,从而消除了锁的使用流程。

这种在锁优化中被称作“锁消除”,是JIT编译器对内部锁的具体实现所做的一种优化。在动态编译同步块的时候,JIT编译器可以借助一种被称为逃逸分析(Escape Analysis)的技术来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。

代码语言:javascript
复制
public class SynchronizedElimination {
    public void method() {
        Object object = new Object();
        synchronized (object) {
            System.out.println("hello world");
        }
	}
}

代码中对object这个对象进行加锁,但是object对象的生命周期只在method()方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉。优化成:

代码语言:javascript
复制
public class SynchronizedElimination {
    public void method() {
        Object object = new Object();
        System.out.println("hello world");

	}
}

由于这段优化是在JIT阶段,不是在前端编译阶段,所以常规的反编译方式是无法看到优化后的代码的。所以使用javap查看字节码会发现里面还是会有monitor指令,但是真正执行时是由JIT编译器(即时编译)来进行判定。

总之,在使用synchronized的时候,如果JIT经过逃逸分析之后发现并无线程安全问题的话,就会做锁消除.

三、JVM中锁的优化

简单来说在JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)如果每次都调用Mutex Lock那么将严重的影响程序的性能。

JDK1.6对锁做了很多优化,使用轻量级锁和偏向锁、自旋锁等优化手段.

1、重量级锁:

synchronized借助Monitor监视器锁(Monitor对象),而Monitor监视器锁使用底层操作系统(linux上)的mutex互斥锁,由于这种同步方式的成本非常高,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。因此,后来称这种锁为“重量级锁”。

synchronized其实是借助Monitor(监视器锁)实现的,Monitor是基于C++实现的,在加锁时会调用objectMonitorenter()方法,解锁的时候会调用exit方法。

这种锁依赖于系统的同步函数,在linux上使用mutex互斥锁,最底层实现依赖于futex (fast userspace mutex)(futex 是Linux的一个基础组件,可以用来构建各种更高级别的同步机制,比如锁或者信号量等等)。这些同步函数都涉及到用户态和内核态的切换、进程的上下文切换,成本较高。在JDK1.6之前,synchronized的实现才会直接调用ObjectMonitor的enter()exit(),这种锁被称之为重量级锁。

在JDK 1.6中对锁的实现引入了大量的优化,了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。如锁消除(Lock Elimination)、锁粗化(Lock Coarsening)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销。

2、轻量级锁(Lightweight Locking):

顾名思义,轻量级锁是相对于重量级锁而言的。使用轻量级锁时,不需要申请互斥量,仅仅将Mark Word(Mark Word是对象头的一部分,下面介绍)中的部分字节CAS更新指向线程栈中的Lock Record,如果更新成功,则轻量级锁获取成功,记录锁状态为轻量级锁;否则,说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁

这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒(具体处理步骤下面详细讨论)。 轻量级锁本质:使用CAS取代互斥同步,属于乐观锁。 轻量级锁目标:轻量级锁是相对于重量级锁而言的,减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。

3、偏向锁(Biased Locking):

是为了在无锁竞争的情况下避免在锁获取过程中执行不必要的CAS原子指令,因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟。

在没有锁竞争的情况下,还能够针对部分场景继续优化。如果不仅仅没有实际竞争,自始至终,使用锁的线程都只有一个,那么,维护轻量级锁都是浪费的。 “偏向”的意思是,偏向锁假定将来只有第一个申请锁的线程会使用锁(不会有任何线程再来申请锁),因此,只需要在Mark Word中CAS记录owner(本质上也是更新,但初始值为空),如果记录成功,则偏向锁获取成功,记录锁状态为偏向锁,以后当前线程等于owner就可以零成本的直接获得锁;否则,说明有其他线程竞争,膨胀为轻量级锁偏向锁目标:偏向锁是为了消除无竞争情况下的同步原语,进一步提升程序使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次CAS,但偏向锁只有初始化时需要一次CAS, 偏向锁原理:当线程请求到锁对象后,将锁对象的状态标志位改为01,即偏向模式。然后使用CAS操作将线程的ID记录在锁对象的Mark Word中。以后该线程可以直接进入同步块,连CAS操作都不需要。 偏向锁缺点:同样的,如果明显存在其他线程申请锁,那么偏向锁将很快膨胀为轻量级锁。偏向锁无法使用自旋锁优化,因为一旦有其他线程申请锁,就破坏了偏向锁的假定。

可以使用参数-XX:-UseBiasedLocking禁止偏向锁优化(默认打开)。

4、自旋锁与自适应自旋锁

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,由于内核态与用户态的切换上不容易优化, 但通过自旋锁,可以减少线程阻塞造成的线程切换(包括挂起线程和恢复线程)。

自旋锁定义:许多情况下,共享数据的锁定状态持续时间较短,切换线程不值得,通过让线程执行循环等待锁的释放,不让出CPU。如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式。

自旋锁目标:自旋等待锁的过程线程并不会引起上下文切换(线程切换)

自旋锁缺点:果锁被其他线程长时间占用,一直不释放CPU,会带来许多的性能开销。

  • 单核处理器上,不存在实际的并行,当前线程不阻塞自己的话,旧owner就不能执行,锁永远不会释放,此时不管自旋多久都是浪费;进而,如果线程多而处理器少,自旋也会造成不少无谓的浪费。
  • 自旋锁要占用CPU,如果是计算密集型任务,这一优化通常得不偿失,减少锁的使用是更好的选择。
  • 如果锁竞争的时间比较长,那么自旋通常不能获得锁,白白浪费了自旋占用的CPU时间。这通常发生在锁持有时间长,且竞争激烈的场景中,此时应主动禁用自旋锁。

适用场景 :如果是多核处理器,如果预计线程等待锁的时间很短,短到比线程两次上下文切换时间要少的情况下,使用自旋锁是划算的

在JDK1.6之后,自旋锁是默认开启,使用-XX:-UseSpinning参数关闭自旋锁优化;自旋次数的默认值是10,使用-XX:PreBlockSpin参数修改默认的自旋次数。

自适应自旋锁:适应自旋可以根据以往自旋等待时间的经验,计算出一个较为合理的本次自旋等待时间。这种相当于是对上面自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点。

自适应自旋目标:解决的是“锁竞争时间不确定”的问题。JVM很难感知到确切的锁竞争时间,而交给用户分析就违反了JVM的设计初衷。自适应自旋假定不同线程持有同一个锁对象的时间基本相当,竞争程度趋于稳定,因此,可以根据上一次自旋的时间与结果调整下一次自旋的时间

当线程在获取轻量级锁的过程中执行CAS操作失败时,在进入与monitor相关联的操作系统重量级锁(mutex semaphore)前会进入忙等待(Spinning)然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该monitor关联的semaphore(即互斥锁)进入到阻塞状态。

自旋锁阻塞锁最大的区别就是,到底要不要放弃处理器的执行时间。对于阻塞锁自旋锁来说,都是要等待获得共享资源。但是阻塞锁是放弃了CPU时间,进入了等待区,等待被唤醒。而自旋锁是一直“自旋”在那里,时刻的检查共享资源是否可以被访问。

由于自旋锁只是将当前线程不停地执行循环体,不进行线程状态的改变,所以响应速度更快。但当线程数不停增加时,性能下降明显,因为每个线程都需要执行,占用CPU时间。如果线程竞争不激烈,并且保持锁的时间段。适合使用自旋锁。

四、synchronized锁的底层实现和monitor对象相关

Synchronized同步和monitor对象有关,而monitor则和对象头有关, 对象头重要的是mark word(记录锁相关信息)

1、monitor对象和机制

每一个锁都对应一个monitor对象,在HotSpot虚拟机中它是由ObjectMonitor实现的(C++实现)。每个对象都存在着一个monitor与之关联,对象与其monitor之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个monitor被某个线程持有后,它便处于锁定状态。

在C++中由ObjectMonitor实现,其数据结构如下:

代码语言:javascript
复制
 ObjectMonitor() {
    _header       = NULL;
    _count        = 0;
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL;
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ;
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

ObjectWaiter: 每个等待锁的线程都会被封装成ObjectWaiter对象

可以关注下几个比较关键的属性:

  • _owner 指向持有ObjectMonitor对象的线程
  • _WaitSet 存放处于wait状态的线程
  • _EntryList 存放处于等待锁block状态的线程队列
  • _recursions 锁的冲入次数
  • _count 记录该线程获取锁的次数
  • Owner:初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL。
  • EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。
  • RcThis:表示blocked或waiting在该monitor record上的所有线程的个数。
  • Nest:用来实现重入锁的计数。HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)。
  • Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。 Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁。

Monitor可以类比为一个特殊房间,这个房间会记录一些被保护的数据,Monitor保证每次只能有一个线程能进入这个房间进行访问被保护的数据,进入房间后即为持有Monitor,退出房间即为释放Monitor。

1、线程排队获得锁:当多个线程同时访问一段同步代码时,首先会进入_EntryList队列中排队。

2、线程获得锁成功:当某个线程获得对象monitor后在进入_Owner区域,并将monitor_owner设为当前线程,同时monitor中的计数器_count加1。即获得锁。

3、释放锁:若持有mnitor对象的线程调用了wait()方法会释放monitor_ownernull,计数器_count减一,进入到_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。

当该对象调用了notify方法或者notifyAll方法后,_WaitSet中的线程就会被唤醒,然后在_WaitSet队列中被唤醒的线程和_EntryList队列中的线程一起通过CPU调度来竞争对象的Monitor,最终只有一个线程能获取对象的Monitor。

需要注意的是:

  • 当一个线程在wait-set中被唤醒后,并不一定会立刻获取Monitor,它需要和其他线程去竞争
  • 如果一个线程是从wait-set队列中唤醒后,获取到的Monitor,它会去读取它自己保存的PC计数器中的地址,从它调用wait方法的地方开始执行。1、monitor对象和机制

2、synchronized锁的底层实现通过monitor对象机制

通过synchronized关键字实现线程同步来获取对象的Monitor。synchronized同步分为以下两种方式:

1 )、同步代码块

synchronized(Obejct syncObj) { //同步代码块 ... }

上述代码表示在进入同步代码块之前,先要去获取syncObj的Monitor,如果已经被其他线程获取了,那么当前线程必须等待直至其他线程释放syncObj的Monitor

这里的syncObj可以是类.class,表示需要去获取该类的字节码的Monitor,获取后,其他线程无法再去获取到class字节码的Monitor了,即无法访问属于类的同步的静态方法了,但是对于对象的实例方法的访问不受影响

2)同步方法

public class Test { public static Test instance;

public int val;

public synchronized void set(int val) { this.val = val; }

public static synchronized void set(Test instance) { Test.instance = instance; }

}

上述使用了synchronized分别修饰了非静态方法和静态方法。 非静态方法可以理解为,需要获取当前对象this的Monitor,获取后,其他需要获取该对象的Monitor的线程会被堵塞。 静态方法可以理解为,需要获取该类字节码的Monitor(因为static方法不属于任何对象,而是属于类的方法),获取后,其他需要获取字节码的Monitor的线程会被堵塞。

3、JVM中的对象结构

Synchronized同步和monitor对象有关,而monitor则和对象头有关。

在JVM中的对象是分成三部分存在的:对象头、实例数据、对其填充。

对象头:包括两部分:Mark Word 和 类型指针,如果是数组的话还有有额外的部分存放数组长度。

实例数据:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐;

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

4、对象头结构

Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit)

1)Mark Word数据:第一部分用于存储对象自身运行时数据,这部分数据存储对象的hashCode、锁信息或分代年龄或GC标志等信息。官方称为Mark Word

2)类型指针:另一部分用于存储类型指针(指向对象的类元数据的指针)JVM通过类型指针确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说查找对象的元数据信息并不一定要经过对象本身。

3)数组长度:如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。这部分数据的长度也随着JVM架构的不同而不同:32位的JVM上,长度为32位;64位JVM则为64位。64位JVM如果开启+UseCompressedOops选项,该区域长度也将由64位压缩至32位。

对象头它、是synchronized实现锁的基础,因为synchronized申请锁、上锁、释放锁都与对象头有关。

5、Mark Word

Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,占用内存大小与虚拟机位长一致

在32位系统上mark word长度为32字节,64位系统上长度为64字节。为了能在有限的空间里存储下更多的数据,其存储格式是不固定的,在32位系统上各状态的格式如下:

可以看到锁信息也是存在于对象的mark word中的,不同的锁状态,mark word信息不同: 偏向锁时:当对象状态为偏向锁(biasable)时,mark word存储的是偏向的线程ID; 轻量级锁时:当状态为轻量级锁(lightweight locked)时,mark word存储的是指向线程栈中Lock Record的指针; 重量级锁时:当状态为重量级锁(inflated)时(通常说synchronized的对象锁),为指向堆中的monitor对象的指针(monitor对象的起始地址。)。

五、偏向锁、轻量级锁、重量级锁实现


简单来说在JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)如果每次都调用Mutex Lock那么将严重的影响程序的性能。

1、重量级锁:

重量级锁的状态下,对象的mark word为指向一个堆中monitor对象的指针。 一个monitor对象包括这么几个关键字段:cxq(下图中的ContentionList),EntryList ,WaitSet,owner。 其中cxq ,EntryList ,WaitSet都是由ObjectWaiter的链表结构,owner指向持有锁的线程。

当一个线程尝试获得锁时,如果该锁已经被占用,则会将该线程封装成一个ObjectWaiter对象插入到cxq的队列尾部,然后暂停当前线程。当持有锁的线程释放锁前,会将cxq中的所有元素移动到EntryList中去,并唤醒EntryList的队首线程。

如果一个线程在同步块中调用了Object#wait方法,会将该线程对应的ObjectWaiter从EntryList移除并加入到WaitSet中,然后释放锁。当wait的线程被notify之后,会将对应的ObjectWaiter从WaitSet移动到EntryList中。

以上只是对重量级锁流程的一个简述,其中涉及到的很多细节,比如ObjectMonitor对象从哪来?释放锁时是将cxq中的元素移动到EntryList的尾部还是头部?notfiy时,是将ObjectWaiter移动到EntryList的尾部还是头部?

2、轻量级锁

JVM的开发者发现在很多情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替的执行同步块中的代码。这种情况下,用重量级锁是没必要的。因此JVM引入了轻量级锁的概念。

轻量级锁相对于使用操作系统互斥量的互斥锁(重量级锁)synchronized、Lock而言的,使用CAS操作实现。

线程在执行同步块之前,JVM会先在当前的线程的栈帧中创建一个Lock Record,其包括一个用于存储对象头中的 mark word(官方称之为Displaced Mark Word)以及一个指向对象的指针。下图右边的部分就是一个Lock Record。

-XX:+UseHeavyMonitors可以禁用轻量级锁和偏向锁。

加锁过程:

1)创建一个Lock Record:线程在执行同步块时,检测同步对象如果没有被锁定(01), 则JVM会先在当前的线程的栈帧中创建一个Lock Record(虚拟机栈为线程独享,栈帧为其栈元素),Lock Record包括一个用于存储对象头中的 mark word 以及一个指向对象的指针(下图Object reference 字段指向锁对象)。下图右边的部分就是一个Lock Record。( mark word 官方称之为Displaced Mark Word)。

2)更新锁状态:JVM通过CAS指令尝试将对象的mark word 更新为指向栈帧的lock recode的指针,如果对象处于无锁状态则修改成功,将对象的锁标记更新为00,此对象处于轻量级锁的状态。如果失败,进入到步骤3。

3)如果是当前线程已经持有该锁了,代表这是一次锁重入。当CAS更新操作失败了,则JVM检查锁对象的mark word是否指向当前线程的栈帧,是则说明线程已经拥有此对象锁,进入同步代码块执行, 否则说明锁对象被其他线程抢占,两条以上的线程争用一个锁,轻量级锁膨胀为重量级锁,锁对象状态值变为10,锁对象mark down存储的是指向重量级锁/互斥量/互斥锁的指针,等待锁的线程会进入阻塞状态。

解锁过程

1.遍历线程栈,找到所有Object reference字段等于当前锁对象的Lock Record。 2.如果Lock Record的Displaced Mark Word为null,代表这是一次重入,将obj设置为null后continue。 3.如果Lock Record的Displaced Mark Word不为null,则利用CAS指令将对象头的mark word恢复成为Displaced Mark Word。如果成功,则continue,否则膨胀为重量级锁。

3、偏向锁

Java是支持多线程的语言,因此在很多二方包、基础库中为了保证代码在多线程的情况下也能正常运行,也就是我们常说的线程安全,都会加入如synchronized这样的同步语义。但是在应用在实际运行时,很可能只有一个线程会调用相关同步方法。比如下面这个demo:

代码语言:javascript
复制
import java.util.ArrayList;
import java.util.List;
public class SyncDemo1 {
   public static void main(String[] args) {
       SyncDemo1 syncDemo1 = new SyncDemo1();
       for (int i = 0; i < 100; i++) {
           syncDemo1.addString("test:" + i);
       }
   }
   private List<String> list = new ArrayList<>();
   public synchronized void addString(String s) {
       list.add(s);
   }
}

在这个demo中为了保证对list操纵时线程安全,对addString方法加了synchronized的修饰,但实际使用时却只有一个线程调用到该方法,对于轻量级锁而言,每次调用addString时,加锁解锁都有一个CAS操作;对于重量级锁而言,加锁也会有一个或多个CAS操作(这里的’一个‘、’多个‘数量词只是针对该demo,并不适用于所有场景)。

在JDK1.6中为了提高一个对象在一段很长的时间内都只被一个线程用做锁对象场景下的性能,引入了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只会执行几个简单的命令,而不是开销相对较大的CAS命令。我们来看看偏向锁是如何做的。

偏向锁更近一步,在无竞争的情况下直接把整个同步消除掉。如果程序大多数锁总是被多个线程访问则没必要开启。-XX:+UseBiasedLocking开启偏向锁。

对象创建

当JVM启用了偏向锁模式(1.6以上默认开启),当新创建一个对象的时候,如果该对象所属的class没有关闭偏向锁模式(什么时候会关闭一个class的偏向模式下文会说,默认所有class的偏向模式都是是开启的),那新创建对象的mark word将是可偏向状态,此时mark word中的thread id(参见上文偏向状态下的mark word格式)为0,表示未偏向任何线程,也叫做匿名偏向(anonymously biased)。

加锁过程

case 1:当该对象第一次被线程获得锁的时候,发现是匿名偏向状态,则会用CAS指令,将mark word中的thread id由0改成当前线程Id。如果成功,则代表获得了偏向锁,继续执行同步块中的代码。否则,将偏向锁撤销,升级为轻量级锁。

case 2:当被偏向的线程再次进入同步块时,发现锁对象偏向的就是当前线程,在通过一些额外的检查后(细节见后面的文章),会往当前线程的栈中添加一条Displaced Mark Word为空的Lock Record中,然后继续执行同步块的代码,因为操纵的是线程私有的栈,因此不需要用到CAS指令;由此可见偏向锁模式下,当被偏向的线程再次尝试获得锁时,仅仅进行几个简单的操作就可以了,在这种情况下,synchronized关键字带来的性能开销基本可以忽略。

case 3.当其他线程进入同步块时,发现已经有偏向的线程了,则会进入到撤销偏向锁的逻辑里,一般来说,会在safepoint中去查看偏向的线程是否还存活,如果存活且还在同步块中则将锁升级为轻量级锁,原偏向的线程继续拥有锁,当前线程则走入到锁升级的逻辑里;如果偏向的线程已经不存活或者不在同步块中,则将对象头的mark word改为无锁状态(unlocked),之后再升级为轻量级锁。

由此可见,偏向锁升级的时机为:当锁已经发生偏向后,只要有另一个线程尝试获得偏向锁,则该偏向锁就会升级成轻量级锁。当然这个说法不绝对,因为还有批量重偏向这一机制。

解锁过程

当有其他线程尝试获得锁时,是根据遍历偏向线程的lock record来确定该线程是否还在执行同步块中的代码。因此偏向锁的解锁很简单,仅仅将栈中的最近一条lock record的obj字段设置为null。需要注意的是,偏向锁的解锁步骤中并不会修改对象头中的thread id。

下图展示了锁状态的转换流程:

另外,偏向锁默认不是立即就启动的,在程序启动后,通常有几秒的延迟,可以通过命令 -XX:BiasedLockingStartupDelay=0来关闭延迟。

批量重偏向与撤销

从上文偏向锁的加锁解锁过程中可以看出,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时将偏向锁撤销为无锁状态或升级为轻量级/重量级锁。safe point这个词我们在GC中经常会提到,其代表了一个状态,在该状态下所有线程都是暂停的(大概这么个意思),详细可以看这篇文章。总之,偏向锁的撤销是有一定成本的,如果说运行时的场景本身存在多线程竞争的,那偏向锁的存在不仅不能提高性能,而且会导致性能下降。因此,JVM中增加了一种批量重偏向/撤销的机制。

https://blog.csdn.net/ITer_ZC/article/details/41892567

存在如下两种情况:(见官方论文第4小节):

https://www.oracle.com/technetwork/java/biasedlocking-oopsla2006-wp-149958.pdf

1.一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种case下,会导致大量的偏向锁撤销操作。

2.存在明显多线程竞争的场景下使用偏向锁是不合适的,例如生产者/消费者队列。

批量重偏向(bulk rebias)机制是为了解决第一种场景。批量撤销(bulk revoke)则是为了解决第二种场景。

其做法是:以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。每个class对象会有一个对应的epoch字段,每个处于偏向锁状态对象的mark word中也有该字段,其初始值为创建该对象时,class中的epoch的值。每次发生批量重偏向时,就将该值+1,同时遍历JVM中所有线程的栈,找到该class所有正处于加锁状态的偏向锁,将其epoch字段改为新值。下次获得锁时,发现当前对象的epoch值和class的epoch不相等,那就算当前已经偏向了其他线程,也不会执行撤销操作,而是直接通过CAS操作将其mark word的Thread Id 改成当前线程Id。

当达到重偏向阈值后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后,对于该class的锁,直接走轻量级锁的逻辑。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2018/01/19 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、synchronized概述
    • 1、synchronized特性
      • 1.1、原子性:
        • 1.2、内存可见性:
          • 1.1.3 有序性
            • 1.1.4 可重入性
              • 2、synchronized的用法和作用域
                • 1)、synchronized修饰类方法
                • 2)synchronized修饰静态方法
                • 3)、synchronized同步代码块
            • 二、synchronized代码块底层原理
              • 2.1、同步代码块:
                • 2.2 同步块的Synchronized的底层实现原理:
                  • 2.3、同步方法
                    • 2.3、Synchronized总结
                    • 三、JIT编译锁的优化
                      • 1、JIT(just in time)即时编译技术
                        • 2、锁粗化(Lock Coarsening):
                          • 3、锁消除(Lock Elimination)
                          • 三、JVM中锁的优化
                            • 1、重量级锁:
                              • 2、轻量级锁(Lightweight Locking):
                                • 3、偏向锁(Biased Locking):
                                  • 4、自旋锁与自适应自旋锁
                                  • 四、synchronized锁的底层实现和monitor对象相关
                                    • 1、monitor对象和机制
                                      • 2、synchronized锁的底层实现通过monitor对象机制
                                        • 1 )、同步代码块
                                        • 2)同步方法
                                      • 3、JVM中的对象结构
                                        • 4、对象头结构
                                          • 5、Mark Word
                                          • 五、偏向锁、轻量级锁、重量级锁实现
                                            • 1、重量级锁:
                                              • 2、轻量级锁
                                            • 3、偏向锁
                                            相关产品与服务
                                            数据保险箱
                                            数据保险箱(Cloud Data Coffer Service,CDCS)为您提供更高安全系数的企业核心数据存储服务。您可以通过自定义过期天数的方法删除数据,避免误删带来的损害,还可以将数据跨地域存储,防止一些不可抗因素导致的数据丢失。数据保险箱支持通过控制台、API 等多样化方式快速简单接入,实现海量数据的存储管理。您可以使用数据保险箱对文件数据进行上传、下载,最终实现数据的安全存储和提取。
                                            领券
                                            问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档