Synchronized实现原理,你知道多少?
1.synchronized的作用是什么
synchronized也叫作同步锁,解决的是多个线程之间对资源的访问一致性。换句话说,就是保证在同一时刻,被synchronized修饰的方法或代码块只有一个线程在执行,其他线程必须等待,解决并发安全问题。
其可以支持原子性、可见性和有序性。三大特性的说明
2.synchronized的应用
2.1锁的分类
synchronized的锁可分为类锁和对象锁。
1)类锁
是用来锁类的,让所有对象共同争抢一把锁。一个类的所有对象共享一个class对象,共享一组静态方法,类锁的作用就是使持有者可以同步地调用静态方法。当synchronized修饰静态方法或者class对象的时候,拿到的就是类锁。
2)对象锁
是用来锁对象的,虚拟机为每个的非静态方法和非静态域都分配了自己的空间,每个对象各有一把锁,即同一个类如果有两个对象就有两把锁。synchronized修饰非静态方法或者this时拿到的就是对象锁。
2.2锁的应用
synchronized可用于方法和代码块。其中方法分为普通方法(非静态方法)和静态方法,而修饰代码块时又可以修饰类(xxx.class)和当前对象(this)。下面通过使用是否使用同步锁的现象来进行说明:
1)静态方法
先看下面的案例,这里使用两个线程对用一个数据进行操作,两次的结果理想值应该分别是10000和20000。(多了效果的直观性,这里循环此时比较大,每个测试方法建议运行多次,避免出现偶然的情况达到理想情况),打印结果一定是什么?
public class TestUtil{ private static int i = 0; //共享资源 //基础方法,数字累加 public static void baseMethod() { for (int j = 0; j < 10000; j++) { i++; } System.out.println(i); } //静态方法,没有使用同步锁 public static void addS() { baseMethod(); } } public class SynchronizedTest { public static void main(String[] args) { test1(); } public static void test1() { new Thread(() -> { TestUtil.addS(); }).start(); new Thread(() -> { TestUtil.addS(); }).start(); } }
打印结果,根据结果可以看出,并不是理想值,导致数据错乱。这就是典型的多线程问题,面对这样的问题,不得不加锁。这时,只需要在方法上加同步锁即可。
//TestUtil public synchronized static void addS2() { baseMethod(); } //SynchronizedTest public static void test2() { new Thread(() -> { TestUtil.addS2(); }).start(); new Thread(() -> { TestUtil.addS2(); }).start(); }
运行test2()后结果已符合理想情况。
分析:当对静态方法加锁后,就使用了类锁,这个类的所有对象共享这个静态方法,这个方法就是同步方法。换句话说,就是当一个线程执行一个对象中的同步方法时,其他线程调用同一对象上的同步方法将会被阻塞,直到第一个线程完成使用这个对象。
2)普通方法
原始代码如下,工具类实现了Runnable接口(或继承Thread类,若不做此操作则无法看出效果)重写run方法
public class TestUtil implements Runnable{ private static int i = 0; //共享资源 @Override public void run() { add(); } //基础方法,数字累加 public static void baseMethod() { for (int j = 0; j < 10000; j++) { i++; } System.out.println(i); } //非静态方法,没有使用同步锁 public void add() { baseMethod(); } } public class SynchronizedTest { public static void main(String[] args) { test3(); } public static void test3() { TestUtil test = new TestUtil(); new Thread(test).start(); new Thread(test).start(); } public static void test4() { TestUtil test = new TestUtil(); TestUtil test2 = new TestUtil(); new Thread(test).start(); new Thread(test2).start(); } }
test3()运行结果,test4()的运行结果也是相似。这时,是不是只需要在方法上加同步锁就可以了呢?
//TestUtil public synchronized void add2() { baseMethod(); } @Override public void run() { add2(); }
加锁后并修改run方法中调用的方法名,运行test3()已符合理想情况。但test4()却还是错乱,都已经是同步方法了,这是为什么呢?其实很简单,对非静态方法加锁后,就使用了对象锁,对于test3(),两个线程都使用的同一个对象,自然是对这个对象加锁,从而保证数据的安全。而test4()创建了两个对象,两个线程使用的是两个对象,这两个对象分别只对自己加锁丝毫不影响别的对象的锁。自然会出现问题。那么究竟要怎么办呢?既然是使用的不同的锁,那么让它们使用同一把锁不就好了吗,只需要将这个非静态的同步方法改为静态同步方法即可。
3)同步代码块
所谓的同步代码块就是只对某一部分代码加锁,而不是对整个的方法加锁,因为若方法体比较大,涉及到同步的代码又只是一小部分,如果对方法加锁性能比较差。同步代码块可以对class对象(类锁)或this加锁(对象锁),下面以类锁进行说明
@Override public void run() { //其他操作... //对象锁 // synchronized (this) { } //类锁 synchronized (TestUtil.class) { for (int j = 0; j < 10000; j++) { i++; } } System.out.println(i); }
在测试时虽然加了同步锁,但无论是调用test3()还是test4()都无法达到理想结果,第一次输出的值是变化的,第二次的值才是20000。为了解决这个问题,我们可以把需要加锁的那块代码抽出来,封装成静态方法,然后对该方法加锁,如下即可
同步静态方法需要对变量进行返回,原因很简单,就是在加锁的时候,还未释放锁,其他线程去获取的变量值可能不是最新的,只有当前释放锁后才会把最新的值返回。
3.synchronized底层实现原理
synchronized的底层实现是完全依赖JVM虚拟机的,所以先看看对象的存储结构。
3.1对象结构
JVM是虚拟机,是一种标准规范,主要作用就是运行java的类文件的。而虚拟机有很多实现版本,HotSpot就是虚拟机的一种实现,是基于热点代码探测的,有JIT即时编译功能,能提供更高质量的本地代码。HotSpot 虚拟机中对象在内存中可分为对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。其组成结构如下图:
1)实例数据:存放类的属性数据信息,包括父类的属性信息。如果是数组,那么实例部分还包括数组的长度,这部分内存按4字节对齐。
2)对齐填充:虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
3)对象头
对于元数据指针,虚拟机通过这个指针来确定这个对象是哪个类的实例;
对于标记字段,用于存储对象自身的运行时数据,其组成如下图
(锁信息占3位)在jdk1.6之前只有重量级锁,而1.6后对其进行了优化,就有了偏向锁和轻量级锁。
3.2上锁的原理
对于同步代码块来说,主要使用monitor(监视器)实现的,就相当于一个房间一次只能被允许一个进程进入。主要包含monitorenter和monitorexit。其中monitorenter指向同步代码块开始的位置,monitorexit指向同步代码块结束的位置。当执行monitorenter时,线程试图monitor的持有权,当monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。若其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。当执行monitorexit时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor。
对于同步方法来说,使用的是ACC_SYNCHRONIZED 标识,通过该标识指明该方法是否是一个同步方法。
3.3锁升级
所谓的上锁,就是把锁的信息记录在对象头中,默认是无锁的,当达到一定的条件时会进行锁升级,会按照下面的顺序依次升级。
- 无锁:没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。
- 偏向锁:作用是消除数据在无竞争情况下的同步原语,进一步提高程序的性能。大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要同步(使用同步锁的情况下)。
- 轻量级锁:当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞。目的是在没有多线程竞争的情况下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗,从而提高性能。
- 重量级锁:指的是原始的Synchronized的实现,当使用同步锁后,其他线程试图获取锁时,都会被阻塞,只有持有锁的线程释放锁之后才会唤醒这些线程。
如何查看对象的信息呢?下面以User对象进行说明:
①User对象
public class User { public String name; private String pwd; private Integer age; //get,set等方法在此省略 }
②导入依赖,用于获取对象头信息
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
③打印对象信息
package com.zxh.demo; import com.zxh.demo.entity.User; import org.openjdk.jol.info.ClassLayout; public class LockTest { public static void main(String[] args) { User user = new User(); System.out.println("对象结构:(格式化)"); System.out.println(ClassLayout.parseInstance(user).toPrintable()); } }
打印结果如下:
其中A和B区域是对象头信息,C是对象的成员变量(包含了默认值)。而A就是mark-down的信息,B是元数据指针。对A来说,有两行,也就是64位,但是在看二进制时需要反过来看。比如上述64位正常情况下拼接(00000001 00000000 00000000 00000000 00000000 00000000 00000000 00000000),但实际时应该是(00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000001),因此最后的八位在上述mark-down图中就是最后面的8位,那么对于锁信息就是001,也就是无锁。
那么怎么将锁升级为偏向锁呢?JVM默认延时超过4s会开启偏向锁,也可以在程序启动时去指定。这里通过程序休眠的方式去开始偏向锁:
public static void main(String[] args) throws InterruptedException { Thread.sleep(5000); User user = new User(); System.out.println("启用偏向锁"); System.out.println(ClassLayout.parseInstance(user).toPrintable()); for (int i = 0; i < 2; i++) { synchronized (user) { System.out.println("偏向锁101:" + ClassLayout.parseInstance(user).toPrintable()); } System.out.println("释放偏向锁 :" + ClassLayout.parseInstance(user).toPrintable()); } }
从下面的运行结果可以看出,启用偏向锁后,升级为偏向锁(锁信息为101),对象头中就记录了线程的id
虽然在代码中释放了偏向锁,但对象头中的信息不会改变,原因是偏向锁不会主动释放,方便同一个线程下次直接去执行被加锁的代码块。
此时,如果再加入线程对对象加锁,那么立马就会变为轻量级锁:
public static void main(String[] args) throws InterruptedException { Thread.sleep(5000); User user = new User(); System.out.println("启用偏向锁"); System.out.println(ClassLayout.parseInstance(user).toPrintable()); for (int i = 0; i < 2; i++) { synchronized (user) { System.out.println("偏向锁101:" + ClassLayout.parseInstance(user).toPrintable()); } System.out.println("释放偏向锁 :" + ClassLayout.parseInstance(user).toPrintable()); } new Thread(()->{ synchronized (user) { System.out.println("轻量级锁000:" + ClassLayout.parseInstance(user).toPrintable()); } }).start(); } }
运行结果部分截图:
此时,如果还有其他线程继续对此对象加锁(必要时可加延时),那么就会升级为重量级锁:
public static void main(String[] args) throws InterruptedException { Thread.sleep(5000); User user = new User(); System.out.println("启用偏向锁"); System.out.println(ClassLayout.parseInstance(user).toPrintable()); for (int i = 0; i < 2; i++) { synchronized (user) { System.out.println("偏向锁101:" + ClassLayout.parseInstance(user).toPrintable()); } System.out.println("释放偏向锁 :" + ClassLayout.parseInstance(user).toPrintable()); } new Thread(()->{ synchronized (user) { System.out.println("轻量级锁000:" + ClassLayout.parseInstance(user).toPrintable()); try { Thread.sleep(3000); } catch (InterruptedException e) { throw new RuntimeException(e); } System.out.println("轻量级锁-> 重量级锁:" + ClassLayout.parseInstance(user).toPrintable()); } }).start(); Thread.sleep(1000); new Thread(()->{ synchronized (user) { System.out.println("重量级锁010:" + ClassLayout.parseInstance(user).toPrintable()); } }).start(); }
运行结果部分截图:
因此,锁升级的原理是:默认情况下是无锁的,此时对象头中threadId的值为空,但在手动开始或延迟4秒后会进入偏向锁。在进入偏向锁时,会将threadId设置为此线程的id,那么当线程再次进入时 ,会判断此线程的id是否和threadId一致。如果一致则可以直接使用此对象,若不一致(有其他线程进入)则会升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁。如果执行一定次数后还没有正常获取到要使用的对象,则会升级轻量级锁为重量级锁。