synchronized详解

一、基本使用

synchronized是Java并发编程中的同步机制关键字,它能保证同一个时刻只有一条线程能够执行被关键字修饰的代码,其他线程就会在队列中进行等待,等待这条线程执行完毕后,下一条线程才能对执行这段代码。


synchronized的3种使用方式:
在这里插入图片描述

  1. 修饰实例方法:作用于当前实例加锁
  2. 修饰静态方法:作用于当前类对象加锁
  3. 修饰代码块:指定加锁对象,进入同步代码块前要获得给定对象的锁

1.1、对象锁

类加载后,我们可以new出很多个实例对象,每个实例对象在JVM中都有自己的引用地址和堆内存空间,这些实例都是独立的个体,所以在实例上加锁和其他的实例肯定没有关系,不同实例的锁互不影响。


当一个对象中有同步方法或者同步代码块时,线程调用此对象进入同步区域前,就必须获得对象锁。如果此对象的对象锁被其他调用者占用,则进入阻塞队列,等待此锁被释放(同步块正常返回或者抛异常终止,由JVM自动释放对象锁)。


注意:使用对象锁时,当一个线程访问一个带synchronized的方法时,由于对象锁的存在,该对象中所有加synchronized的方法都不能被访问。

public class ObjectLock {
    public static void main(String[] args) {
        TestObjectLock objectLock = new TestObjectLock();
        new Thread(objectLock::test1).start();
        new Thread(objectLock::test2).start();
    }

    static class TestObjectLock {
        public synchronized void test1() {
            System.out.println("test1..." + System.currentTimeMillis());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        public synchronized void test2() {
            System.out.println("test2..." + System.currentTimeMillis());
        }
    }
}

在这里插入图片描述


1.2、类锁

每个class其中的静态方法和静态变量在JVM中只会加载和初始化一份,所以当静态方法被加上synchronized关键字后,此类的所有的实例化对象在调用该方法时,都会共用同一把锁,称之为类锁。


不管多少对象都共用同一把锁,都将是同步执行,一个线程执行结束,其他的才能够调用同步的部分,不同的类锁互不影响。

public class ClassLock {
    public static void main(String[] args) {
        new Thread(TestClassLock::test1).start();

        TestClassLock classLock = new TestClassLock();
        new Thread(classLock::test2).start();
    }

    static class TestClassLock {
        public static synchronized void test1() {
            System.out.println("test1..." + System.currentTimeMillis());
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        public void test2() {
            synchronized (TestClassLock.class) {
                System.out.println("test2..." + System.currentTimeMillis());
            }
        }
    }
}

在这里插入图片描述


二、原理解析

在JDK1.6之前,synchronized属于重量级,是一个效率比较低下的锁,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,挂起线程和恢复线程都需要转入内核态去完成,阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态切换需要耗费处理器时间,时间成本相对较高。


在JDK1.6后,JVM为了提高锁的获取与释放效率对 synchronized 进行了优化,引入了偏向锁轻量级锁,所以锁的状态就有了四种(无锁、偏向锁、轻量级锁、重量级锁),会随着竞争的激烈而逐渐升级。


2.1、对象头规范

锁是加在对象上的,无论这个对象是类对象还是实例对象,在对象的内存布局及访问定位中,我们了解到对象由三部分组成,对象头实例数据对齐填充。synchronized关键字使用的锁对象是存储在Java对象头里的,而对象头结构是由 mark word 和 klass pointer 组成(对象是数组还有数组长度描述)。可参考文档:https://openjdk.org/groups/hotspot/docs/HotSpotGlossary.html

在这里插入图片描述


2.1.1、对象头内容

其中有关对象头中的内容,可以查看hotspot源码中的注释,如下:
在这里插入图片描述

我们将简单整理一下,换成可读性较强的表格如下:
在这里插入图片描述


2.1.2、对象头布局

这里我们可以利用jol分析Java的对象布局,首先需要引入依赖,如下:

<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.9</version>
</dependency>

测试代码及结果如下:

public class L {
}
public class App {
    public static void main(String[] args) {
        L l = new L();
        System.out.println(ClassLayout.parseInstance(l).toPrintable());
    }
}

在这里插入图片描述

从上述执行结果中,可以看出整个对象共16 bytes,其中对象头(object header)占12 bytes,还有4 bytes是对齐填充(由于 HotSpot VM 的自动内存管理系统要求对对象的大小必须是 8 字节的整数倍),另外由于这个对象里面没有任何字段,故而对象的实例数据为 0 bytes。 (可自行在L类中添加字段进行查看)


那么对象头里面的12 bytes到底存的是什么呢?上述提到过对象头分为mark word 和 klass pointer 组成。其中mark word占8 bytes,存储着上述表格中的信息,对应jol打印的对象头的第1、2行;而剩余的4 bytes则表示klass pointer类型指针,对于jol打印的对象头的第3行,存储着该对象所对应的class对象加载到元空间中的首地址。
在这里插入图片描述


2.1.3、 大小端模式

接下来我们就对照着上述jol打印的对象头的前两行,结合上述的表格来进行查看,在无锁的情况下,前25位应该是没有使用的,但是在jol对象头中的第一行前25个中是有被使用的,这是怎么回事呢?这里就涉及到来大小端模式存储。


一般家用笔记本都是小端模式,即高字节存在高地址,低字节存在低地址,如下图所示:
在这里插入图片描述

所以根据小端模式的存储,对应上述表格中的信息,jol打印的对象头信息如下:
在这里插入图片描述


2.2、锁状态

JDK1.6版本之后对synchronized的实现进行了各种优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销。锁的状态主要有四种,即无锁、偏向锁、轻量级锁和重量级锁。


2.2.1、无锁

当一个对象被创建之后,还没有线程进入,这个时候对象处于无锁状态,其mark word中的信息如上述截图。


这里需要注意的是,偏向锁虽然默认是启动的,但是会存在延迟,JVM启动以后,大约4秒以后偏向锁才会起作用,所以我们在测试时,可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0


2.2.2、偏向锁

在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,增加耗时)的代价而引入偏向锁。偏向锁是默认开启的:

  • 开启偏向锁:-XX:+UseBiasedLocking
  • 关闭偏向锁:-XX:-UseBiasedLocking
2.2.2.1、偏向锁加锁

当线程获取锁资源时,只有第一次使用CAS将线程ID设置到对象的mark word中,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归该线程所有。


以后当这个线程再次请求锁时,只需要简单的比较一下对象头中的线程ID和当前线程是否一致即可,这样就省去了大量有关锁申请的操作,从而也就提高程序的性能。

public class App {
    public static void main(String[] args) {
        L l = new L();
        System.out.println(ClassLayout.parseInstance(l).toPrintable());

        synchronized (l) {
            System.out.println(ClassLayout.parseInstance(l).toPrintable());
        }
    }
}

在这里插入图片描述


上述表格中还提到了无锁不可偏向的q情况,其实从上述jol对象头 mark word 的布局中也可以看出,因为对象头 mark word 中存放 hashCode 和当前线程指针 Java Thread * 的位置时重复的,所以使用到 hashCode 时,就无法进行偏向,如 set、map key 等情况下,如下列直接打印 hashCode 也可:

public class App {
    public static void main(String[] args) {
        L l = new L();
        // 转化成16进制,方便比较
        System.out.println(Integer.toHexString(l.hashCode()));
        
        System.out.println(ClassLayout.parseInstance(l).toPrintable());
        synchronized (l) {
            System.out.println(ClassLayout.parseInstance(l).toPrintable());
        }
    }
}

在这里插入图片描述


需要注意的是,虽然偏向锁是在对象头的 mark word 中保存在线程的ID,但是其实它也在栈中创建了 lock record 对象,在锁重入的情况下,还会在栈中创建多个 lock record 对象,其示意图如下:
在这里插入图片描述


2.2.2.2、偏向锁升级

有不同的线程请求锁(线程交替执行)时,即当另一个线程试图锁定某个已经被偏向过的对象,JVM 就需要撤销(revoke)偏向锁,并切换到轻量级锁实现。

public class LightWeightLock {
    public static void main(String[] args) throws InterruptedException {
        L l = new L();

        Thread t1 = new Thread(() -> {
            synchronized (l) {
                System.out.println(ClassLayout.parseInstance(l).toPrintable());
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (l) {
                System.out.println(ClassLayout.parseInstance(l).toPrintable());
            }
        });

        t1.start();
        t1.join();
        t2.start();
        t2.join();
    }
}

在这里插入图片描述


上述测试中,t1线程先来加锁并释放锁,完成后t2再来加锁(线程交替执行),但是锁对象已经偏向了t1,所以当t2来加锁就需要升级成为轻量锁了,当t2加锁时需要完成偏向锁的撤销,并将锁对象的 mark word 设置到 lock record 对象中的 displace header 中,再通过cas操作将对象头中存储指向 lock record 的地址,其内存变化如下图:
在这里插入图片描述


当t2执行完后,又会把锁记录释放并且恢复锁对象里面的 mark word 对象,那么t2执行完同步块后的内存如下图:
在这里插入图片描述


2.2.2.3、批量重偏向

如果一个类的大量对象被一个线程t1执行了同步操作,也就是大量对象先偏向了t1,t1同步结束后,另一个线程也将这些对象作为锁对象进行操作,会导偏向锁重偏向的操作。(批量重偏向和批量撤销是针对类的优化,和对象无关。)


默认偏向锁批量重偏向阈值:-XX:BiasedLockingBulkRebiasThreshold = 20

public class BiasedLockingBulkRebias {
    public static void main(String[] args) throws InterruptedException {
        List<L> locks = new ArrayList<>();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 20; i++) {
                L l = new L();
                locks.add(l);
                synchronized (l) {
                    // do nothing
                }
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 20; i++) {
                L l = locks.get(i);
                synchronized (l) {
                    if (i == 18 || i == 19) {
                        System.out.println(ClassLayout.parseInstance(l).toPrintable());
                    }
                }
            }
        });
        
        t1.start();
        t1.join();
        t2.start();
        t2.join();
    }
}

在这里插入图片描述


2.2.2.4、批量撤销

当一个偏向锁如果撤销次数到达40的时候就认为这个对象设计的有问题,那么JVM会把这个对象所对应的类所有的对象都撤销偏向锁,并且新实例化的对象也是不可偏向的。


默认偏向锁批量撤销阈值:-XX:BiasedLockingBulkRevokeThreshold = 40

public class BiasedLockingBulkRevoke {
    public static void main(String[] args) throws InterruptedException {
        List<L> locks = new ArrayList<>();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 40; i++) {
                L l = new L();
                locks.add(l);
                synchronized (l) {
                    // do nothing
                }
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 40; i++) {
                L l = locks.get(i);
                synchronized (l) {
                    // do nothing
                }
            }
        });
        Thread t3 = new Thread(() -> {
            for (int i = 0; i < 40; i++) {
                L l = locks.get(i);
                synchronized (l) {
                    if (i == 37 || i == 38) {
                        L nl = new L();
                        System.out.println(ClassLayout.parseInstance(nl).toPrintable());
                    }
                }
            }
        });

        t1.start();
        t1.join();
        t2.start();
        t2.join();
        t3.start();
        t3.join();
    }
}

在这里插入图片描述

从批量重偏向中,我们得知上述代码在t2执行至 i=19 时,会发现偏向锁重偏向的操作;但是经过了t2的执行,i<19 的对象都是轻量级锁( i>19 由于批量重偏向的原因,全部为偏向锁,偏向t2),所以t3执行时偏向锁撤销是从 i>19 时才开始的,所以当 i=38 时,才会达到偏向锁批量撤销的默认阈值40。


2.2.3、轻量锁

如果偏向锁失败,虚拟机就会升级为轻量级锁。轻量级锁不是为了代替重量级锁,它的本意是在多线程交替执行同步块的情况下,尽量避免重量级锁引起的性能消耗,因为使用轻量级锁时,不需要申请互斥量。


但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁。另外轻量级锁的加锁和解锁都用到了CAS操作。


2.2.3.1、轻量锁的加锁

当关闭偏向锁或者多个线程交替执行持有锁,就会导致偏向锁升级为轻量级锁,其步骤如下:

  1. 使锁对象处于无锁状态(当关闭偏向锁时,会生成一个无锁的 mark word;当偏向锁升级为轻量锁时,会进行偏向锁的撤销),并且JVM会在当前线程的栈帧中创建一个锁记录 lock record
  2. 将锁对象的 mark word 复制到栈帧中的 lock record 中,将 lock record 中的 obj ref 指向当前对象
  3. 利用cas操作尝试将锁对象的 mark word 更新为指向 lock record 的指针,如果成功表示竞争到锁,则将锁标志位变成 00 ,执行同步操作
  4. 如果失败说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位将变成 10 ,后面等待的线程将会进入阻塞状态。
    在这里插入图片描述

2.2.3.2、轻量锁的释放

轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:

  1. 取出在获取轻量级锁保存在 displaced header 中的数据
  2. 用cas操作将取出的数据替换当前锁对象的 mark word 中,如果成功,则说明释放锁成功
  3. 如果cas操作替换失败,说明有其他线程尝试获取该锁,则需要将轻量级锁需要膨胀升级为重量级锁
    在这里插入图片描述

2.2.4、自旋锁

如果轻量级锁加锁失败后,虚拟机还会进行一项称为自旋的优化手段。因为一般线程持有锁的时间都不是太长,所以仅仅为了这一点时间去挂起线程/恢复线程是得不偿失的,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。


不过自旋等待不能代替阻塞,自旋等待本身虽然避免了线程切换的开销,但是要占用处理器时间的。如果持有锁的线程很快就会释放了锁,那么自旋等待的效果就会非常好,反之如果锁被占用的时间很长。那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。


所以自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10次,可以使用参数-XX:PreBlockSpin来进行更改。(不过其实自旋锁在JDK1.4.2中就已经引入,只不过默认是关闭的,可以使用-XX:+UseSpinning参数来开启)。


2.2.4.1、适应性自旋

在JDK 6中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。


在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如第一次自旋次数为10就拿到了锁,则第二次的自旋次数可能调整为13;但是若第一次自旋的次数为10,都还没有拿到锁,则第二次可能会调整为8次。


2.2.5、重量锁

如果说线程经过多次自旋以后还是迟迟没有拿到锁,那么线程就会由用户态切换到内核态,申请一个互斥量,并且将锁对象的 mark word 指向我们的互斥量地址,即升级为重量级锁。


每个Java对象作为锁对象时都可以关联一个Monitor对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 mark word 中就被设置指向 Monitor 对象的指针。其结构如下:
在这里插入图片描述


2.3、锁升级

在详细介绍完上述的锁状态后,这几个状态在实际使用的过程会随着竞争状态逐渐升级,其详细的流程示意图如下:
在这里插入图片描述

  • 4
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值