一篇文章讲透synchronized底层实现原理

  • Post author:
  • Post category:其他


在我们平时的 java 项目中少不了多线程的场景,当我们在使用多线程的时候,不得不关注在使用多线程的过程中可能带来的一些安全问题,比如线程执行的原子性,可见性以及有序性。面对这些问题java提供了很多解决方案,首先我们来看一段代码:

public class Demo {
    int i = 0;
    public void incr(){
        i++;
    }
    public static void main(String[] args) {
        Demo demo = new Demo();
        Thread[] threads=new Thread[2];
        for (int j = 0;j<2;j++) {
            threads[j]=new Thread(() -> { // 创建两个线程
                for (int k=0;k<10000;k++) { // 每个线程跑10000次
                demo.incr();
                }
            });
            threads[j].start();
        }
        try {
            threads[0].join();
            threads[1].join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(demo.i);
    }
}

在上面的案例中,演示了两个线程分别去去调用 demo.incr 方法来对 i 这个变量进行叠加,预期结果 应该是20000,但是实际结果却是小于等于20000的值。


问题原因

这个就是典型的线程安全问题中

原子性

问题的体现。那什么是原子性呢? 在上面这段代码中,count++是属于Java高级语言中的编程指令,而这些指令最终可能会有多条CPU指 令来组成,而count++最终会生成3条指令, 通过javap -v xxx.class 查看字节码指令如下。

public incr()V
L0
LINENUMBER 13 L0
ALOAD 0
DUP
GETFIELD com/gupaoedu/pb/Demo.i : I // 访问变量i
ICONST_1 // 将整形常量1放入操作数栈
IADD // 把操作数栈中的常量1出栈并相加,将相加的结
果放入操作数栈
PUTFIELD com/gupaoedu/pb/Demo.i : I // 访问类字段(类变量),复制给Demo.i这个变
量

这三个操作,如果要满足原子性,那么就需要保证某个线程在执行这个指令时,不允许其他线程干扰, 然后实际上,确实会存在这个问题。


图解问题本质

这就是在多线程环境下,存在的原子性问题,那么,怎么解决这个问题呢? 大家认真观察上面这个图,表面上是多个线程对于同一个变量的操作,实际上是count++这行代码,它 不是原子的。所以才导致在多线程环境下出现这样一个问题。 也就是说,我们只需要保证,count++这个指令在运行期间,在同一时刻只能由一个线程来访问,就可 以解决问题。这就需要引出到今天的我们要讲的内容, 同步锁

Synchronized.


Synchronized的基本应用

synchronized有三种方式来加锁,不同的修饰类型,代表锁的控制粒度:

1.

修饰实例方法

,作用于当前实例加锁,进入同步代码前要获得当前实例的锁 public incr()V L0 LINENUMBER 13 L0 ALOAD 0 DUP GETFIELD com/gupaoedu/pb/Demo.i : I // 访问变量i ICONST_1 // 将整形常量1放入操作数栈 IADD // 把操作数栈中的常量1出栈并相加,将相加的结 果放入操作数栈 PUTFIELD com/gupaoedu/pb/Demo.i : I // 访问类字段(类变量),复制给Demo.i这个变 量。

2

. 静态方法

,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁。

3.

修饰代码块

,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。


锁的实现模型理解

Synchronized到底帮我们做了什么,为什么能够解决原子性呢? 在没有加锁之前,多个线程去调用incr()方法时,没有任何限制,都是可以同时拿到这个i的值进行 ++ 操 作,但是当加了Synchronized锁之后,线程A和B就由并行执行变成了串行执行。


Synchronized的原理

Synchronized是如何实现锁的,以及锁的信息是存储在哪里? 就拿上面分析的图来说,线程A抢到锁 了,线程B怎么知道当前锁被抢占了,这个地方一定会有一个标记来实现,而且这个标记一定是存储在 某个地方。


Markword对象头

这就要引出Markword对象头这个概念了,它是对象头的意思,简单理解,就是一个对象,在JVM内存 中的布局或者存储的形式。 jdk8u: markOop.hpp 在Hotspot虚拟机中,对象在内存中的存储布局,可以分为三个区域:对象头(Header)、实例数据 (Instance Data)、对齐填充(Padding)。


mark-word

:对象标记字段占4个字节,用于存储一些列的标记位,比如:哈希值、轻量级锁的标 记位,偏向锁标记位、分代年龄等。


Klass Pointer

:Class对象的类型指针,Jdk1.8默认开启指针压缩后为4字节,关闭指针压缩( – XX:-UseCompressedOops )后,长度为8字节。其指向的位置是对象对应的Class对象(其对应的 元数据对象)的内存地址。


对象实际数据

:包括对象的所有成员变量,大小由各个成员变量决定,比如:byte占1个字节8比 特位、int占4个字节32比特位。


对齐

:最后这段空间补全并非必须,仅仅为了起到占位符的作用。由于HotSpot虚拟机的内存管理 系统要求对象起始地址必须是8字节的整数倍,所以对象头正好是8字节的倍数。因此当对象实例 数据部分没有对齐的话,就需要通过对齐填充来补全。

我们可以通过ClassLayout打印对象头如下:

public class Demo {
    Object o=new Object();
    public static void main(String[] args) {
        Demo demo=new Demo(); //o这个对象,在内存中是如何存储和布局的。
        System.out.println(ClassLayout.parseInstance(demo).toPrintable());
    }
}

输出内容如下:


关于Synchronized锁的升级

Jdk1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁 等技术来减少锁操作的开销。 锁主要存在四中状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,他们会随着竞 争的激烈而逐渐升级。

这么设计的目的,其实是为了减少重量级锁带来的性能开销,尽可能的在无锁状态下解决线程并发问 题,其中偏向锁和轻量级锁的底层实现是基于自旋锁,它相对于重量级锁来说,算是一种无锁的实现。

默认情况下是偏向锁是开启状态,偏向的线程ID是0,偏向一个Anonymous BiasedLock 如果有线程去抢占锁,那么这个时候线程会先去抢占偏向锁,也就是把markword的线程ID改为当 前抢占锁的线程ID的过程。 如果有线程竞争,这个时候会撤销偏向锁,升级到轻量级锁,线程在自己的线程栈帧中会创建一个 LockRecord,用CAS操作把markword设置为指向自己这个线程的LR的指针,设置成功后表示抢 占到锁。 如果竞争加剧,比如有线程超过10次自旋(-XX:PreBlockSpin参数配置),或者自旋线程数超过 CPU核心数的一般,在1.6之后,加入了自适应自旋Adapative Self Spinning. JVM会根据上次竞争 的情况来自动控制自旋的时间。 升级到重量级锁,向操作系统申请资源, Linux Mutex,然后线程被挂起进入到等待队列。


下面我们具体讲偏向锁,轻量级锁,重量级锁的具体实现原理:


轻量级锁的获取及原理

接下来,我们通过下面的例子来演示一下,通过加锁之后继续打印对象布局信息,来关注对象头里面的 变化。

得到的对象布局信息如下 :

这里很多同学会有疑惑,你不是说锁的升级是基于线程竞争情况,来实现从偏向锁到轻量级锁再 到重量级锁的升级的吗?可是为什么这里明明没有竞争,它的锁的标记是轻量级锁呢?


偏向锁的获取及原理

默认情况下,偏向锁的开启是有个延迟,默认是4秒。为什么这么设计呢? 因为JVM虚拟机自己有一些默认启动的线程,这些线程里面有很多的Synchronized代码,这些 Synchronized代码启动的时候就会触发竞争,如果使用偏向锁,就会造成偏向锁不断的进行锁的升级和 撤销,效率较低。 通过下面这个JVM参数可以讲延迟设置为0。

-XX:BiasedLockingStartupDelay=0

再次运行下面的代码。

得到如下的对象布局,可以看到对象头中的的高位第一个字节最后三位数为[101],表示当前为偏向锁 状态。

·        这里的第一个对象和第二个对象的锁状态都是101,是因为偏向锁打开状态下,默认会有配置匿 名的对象获得偏向锁。

com.gupaoedu.pb.Demo object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 00
00 00 (00000101 00000000 00000000 00000000) (5)
4 4 (object header) 00 00
00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 05 c1
00 f8 (00000101 11000001 00000000 11111000) (-134168315)
12 4 java.lang.Object Demo.o
(object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
com.gupaoedu.pb.Demo object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 05 30
4a 03 (00000101 00110000 01001010 00000011) (55193605)
4 4 (object header) 00 00
00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 05 c1
00 f8 (00000101 11000001 00000000 11111000) (-134168315)
12 4 java.lang.Object Demo.o
(object)
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total


重量级锁的获取

在竞争比较激烈的情况下,线程一直无法获得锁的时候,就会升级到重量级锁。 仔细观察下面的案例,通过两个线程来模拟竞争的场景。

从结果可以看出,在竞争的情况下锁的标记

com.gupaoedu.pb.Demo object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 8a 20 5e 26
(10001010 00100000 01011110 00100110) (643702922)
4 4 (object header) 00 00 00 00
(00000000 00000000 00000000 00000000) (0)
8 4 (object header) 05 c1 00 f8
(00000101 11000001 00000000 11111000) (-134168315
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
t1 lock ing
com.gupaoedu.pb.Demo object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 8a 20 5e 26
(10001010 00100000 01011110 00100110) (643702922)
4 4 (object header) 00 00 00 00
(00000000 00000000 00000000 00000000) (0)
8 4 (object header) 05 c1 00 f8
(00000101 11000001 00000000 11111000) (-134168315)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

为 [010] ,其中所标记 [10]表示重量级锁。


重量级锁

通过监视器(monitor)的方式保障了任何时间只允许一个线程通过受到监视器保护的临界区代码。



JVM

中,每个对象都关联一个监视器,这里的对象包含Object实例和Class实例。监视器是一个同步工具,相当于一个许可证,拿到许可证的线程即可进入临界区进行操作,没有拿到则需要阻塞等待。重量级锁通过监视器的方式保障了任何时间只允许一个线程通过受到监视器保护的临界区代码。


monitor原理

jvm中每个对象都会有一个监视器Monitor,监视器和对象一起创建、销毁。监视器相当于一个用来监视这些线程进入的特殊房间,其义务是保证(同一时间)只有一个线程可以访问被保护的临界区代码块。每一个锁都对应一个monitor对象,在HotSpot虚拟机中它是由ObjectMonitor实现的(C++实现)

ObjectMonitor() {
    _count        = 0;     //锁计数器
    _owner        = NULL;
    _WaitSet      = NULL;  //处于wait状态的线程,会被加入到_WaitSet
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
  }

本质上,监视器是一种同步工具,也可以说是一种同步机制,主要特点是:

1、同步。监视器所保护的临界区代码是互斥地执行的。一个监视器是一个运行许可,任一线程进入临界区代码都需要获得这个许可,离开时把许可归还。

2、协作。监视器提供Signal机制,允许正持有许可的线程暂时放弃许可进入阻塞等待状态,等待其他线程发送Signal去唤醒;其他拥有许可的线程可以发送Signal,唤醒正在阻塞等待的线程,让它可以重新获得许可并启动执行。

每个java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的mark word中就被设置指向 Monitor 对象的指针。

(1) 如果使用 synchronized 给obj对象上锁,obj对象的markword就会指向一个monitor锁对象;

(2) 刚开始 Monitor 中 Owner 为 null ;

(3) 当Thread-2线程持有monitor对象后,就会把monitor中的owner变量设置为当前线程Thread-2;

(4) 当Thread-3线程想要执行临界区的代码时,要判断monitor对象的属性Owner是否为null,如果为null,Thread-3线程就持有了对象锁,如果不为null,Thread-3线程就会放入monitor的EntryList阻塞队列中,处于阻塞状态Blocked。

(5) 在 Thread-2 上锁的过程中,如果Thread-4,Thread-5 也来执行 synchronized(obj),也会进入EntryList BLOCKED ;

(6) Thread-2 执行完同步代码块的内容,就会释放锁,将owner变量置为null,并唤醒EntryList 中阻塞的线程来竞争锁,竞争时是非公平的 ;

(7) 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲 wait-notify 时会分析


snychronized重量级锁同步代码块原理

当使用

snychronized修饰同步块代码,我们对代码进行反编译为字节码文件时会发现涉及到两个字令:


1、monitorenter



每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

(1) 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者

(2) 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.

(3) 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。


2、monitorexit

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


Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出IllegalMonitorStateException的异常的原因。


synchronized同步方法原理

方法的同步并没有通过指令monitorenter和monitorexit来完成不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。


重量级锁的开销

处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,线程的阻塞或者唤醒都需要操作系统来帮忙,Linux内核下采用pthread_mutex_lock系统调用实现,进程需要从用户态切换到内核态。

用户态是应用程序运行的空间,为了能访问到内核管理的资源(例如CPU、内存、I/O),可以通过内核态所提供的访问接口实现,这些接口就叫系统调用。

pthread_mutex_lock系统调用是内核态为用户态进程提供的Linux内核态下互斥锁的访问机制,所以使用pthread_mutex_lock系统调用时,进程需要从用户态切换到内核态,而这种切换是需要消耗很多时间的,有可能比用户执行代码的时间还要长。

由于JVM轻量级锁使用CAS进行自旋抢锁,这些CAS操作都处于用户态下,进程不存在用户态和内核态之间的运行切换,因此JVM轻量级锁开销较小。而JVM重量级锁使用了Linux内核态下的互斥锁,这是重量级锁开销很大的原因。




版权声明:本文为ben040661原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。