Synchronized实现原理,你知道多少?

发布时间 2023-04-03 14:16:35作者: 钟小嘿

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一致。如果一致则可以直接使用此对象,若不一致(有其他线程进入)则会升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁。如果执行一定次数后还没有正常获取到要使用的对象,则会升级轻量级锁为重量级锁。