4. synchronized详解

时间:2022-07-25
本文章向大家介绍4. synchronized详解,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

一.设计同步器的意义

  多线程编程中,有可能会出现多个线程同时访问同一个共享、可变资源的情况,这个资源我们称之其为临界资源;这种资源可能是:对象、变量、文件等。

  共享:资源可以由多个线程同时访问

  可变:资源可以在其生命周期内被修改

  引出的问题:

     由于线程执行的过程是不可控的,所以需要采用同步机制来协同对对象可变状态的访问!

1.1 如何解决线程并发安全问题?

实际上,所有的并发模式在解决线程安全问题时,采用的方案都是序列化访问临界资源。即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。

Java 中,提供了两种方式来实现同步互斥访问:synchronized 和 Lock

同步器的本质就是加锁

加锁目的:序列化访问临界资源,即同一时刻只能有一个线程访问临界资源(同步互斥访问)

不过有一点需要区别的是:当多个线程执行一个方法时,该方法内部的局部变量并不是临界资源,因为这些局部变量是在每个线程的私有栈中,因此不具有共享性,不会导致线程安全问题。

二. synchronized原理详解

synchronized内置锁是一种对象锁(锁的是对象而非引用),作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的。

2.1 Synchronized的发展历史

如上图Synchronized的发展经历了3个阶段. 如上图: 橙色第一阶段, 黄色第二阶段和绿色的第三阶段

第一阶段:

1. jdk<1.6版本的之前, synchronized的效率非常的低, 因为,当时synchronized依赖的加锁方式是java对象锁
2. 当创建java对象的时候, 也就是new Object()的时候,都会天然的创建一个管存对象Monitor
3. synchronized如何加锁成功呢?它依赖于管存对象, 而管存对象依赖于底层的操作系统OS里的Mutex互斥量
4. mutex互斥量是由底层的操作系统维护的, mutex执行的时候会去调用底层的线程库,在linux里面统称为Pthread线程库,
设计到阻塞、升级互斥量等操作,这个是依赖于操作系统的。
之前说过, 我们的底层分为用户空间和内核空间, jvm是在用户空间, 如果他要去调用底层的内核空间是很耗时的。 
所以,刚开始synchronized的效率是非常低的

第二阶段:

这个效率实在是太低了, 于是,Dong li就实现了一个AQS并发框架,里面使用java语言实现了ReentrantLock,  
ReentrantLock实现可重用锁和所有的公性

第三阶段:

后来sun公司被Oracle收购了以后, Synchronized是java的亲儿子, 现在他的性能缺这么低, 还不如ReentrantLock,这怎么可以呢?
于是oracle就优化了Synchnized,在jdk >= 1.6版本以后, 使用的就是升级后的Synchronized锁了. 用户最开始使用的是对象的偏向锁,当并发较大的时候,切换到java轻量级锁, 
当并发量很大的时候, 才切换到操作操作系统底层的Mutex互斥量锁

什么是偏向锁?
刚开始的时候是无锁的状态, 所以,最开始只有一个线程, 没有竞争, 此时的java对象是没有锁的,所以,使用的就是偏向锁。也就是单线程的情况下使用的是偏向锁 

什么是轻量级锁?
上面是只有一个线程的情况, 那么如果有两个线程同时调用一个程序,那么他们之间就会存在竞争, 但是竞争又不是很激烈, 就升级为轻量级锁
如何实现轻量级锁呢?在锁的内部增加一个while循环, 比如加入while循环100次, 如果100次循环结束以后, 锁还没有释放, 那么就会升级为重量级锁。 如果释放了, 那么说明等待的时间不是很久, 
就不需要升级。 升级的时候也是在内部直接升级

何时升级为重量级锁
当竞争更加激烈的时候, 轻量级锁在指定的循环内还没有释放锁, 就说明竞争很激烈了, 这时,就将其升级为重量级锁。


jdk1.6以后, 锁从偏向锁升级到轻量级锁,再到重量级锁, 这个过程是否是可逆的呢?
答案是,这个过程是不可逆的。 因为从重量级锁在退回到轻量级锁也是没有意义的。 为什么呢?你的并发已经很多的。 再退到轻量级锁, 然后再次进行升级, 这样没有意义。
而且锁的升级也是对效率有影响

2.2 加锁的方式:

1、同步实例方法,锁是当前实例对象

public class LockOnClass {

    static int stock;

    public static synchronized void decrStock() {
        System.out.println(--stock);
    }

    public synchronized void addStock() {
        System.out.println(++ stock);
    }

    public static void main(String[] args) {
        LockOnClass.decrStock();
    }
}

这种类型的锁是加载方法级别上的, 加在方法级别的synchronized, 是类实例界别的锁.

2、同步类方法,锁是当前类对象

package com.company;

public class LockOnClass {

    static int stock;

    public static synchronized void decrStock() {
        System.out.println(--stock);
    }

    public static void main(String[] args) {
        LockOnClass.decrStock();
    }
}

可以看到, 当前的锁是加载静态方法上的, 静态方法是类打点直接调用. 也就是说, 这类同步是类级别的同步, 他们的锁是类名.

重点说明 ,看下面这个例子

package com.company;

public class LockOnClass {

    static int stock;

    public static synchronized void decrStock() {
        System.out.println(--stock);
    }

    public static synchronized void addStock() {
        System.out.println(++ stock);
    }

    public static void main(String[] args) {
        LockOnClass.decrStock();
    }
}

这是在一个类的两个静态方法上加了synchronized, 这是非常非常需要重点注意的问题.

在静态方法上家synchronized, 他是类级别的加锁. 两个方法同时加锁, 会大大降低程序的性能.

如果这两个方法被到处调用, 这将是一个灾难性的问题. 这对系统的qps将有一个很大的影响.

重点说明2: 为什么不能再项目里大量的写System.out.println("");

System.out.println(--stock);

原因在这里, out是System类中的一个静态变量. 也就是全局只有一个

再来看看println方法

我们看到println里面有一个synchronized同步标志.

而且,其他的类似代码, 如下图

我们发现他们都有synchronized关键字修饰. 这意味着什么呢?

他们的调用对象都是System.out. 也就是锁是全局唯一的out对象. 如果代码里有很多很多System.out.println(), 那么将大大降低程序的性能.

原因是他们持有的都是一把锁, 要进行锁等待. 所以, 代码里不可以有System.out.println(); 会严重影响我们的qps和tps.

3、同步代码块,锁是括号里面的对象

public class LockOnObject {
    public static Object object = new Object();
    private Integer stock = 10;
    
    public void decrStock() {
        synchronized (object) {
            -- stock;
            if (stock <= 0) {
                System.out.println("库存售罄");
                return;
            }
        }
    }
}

这是对一块代码块进行同步. 锁是定义的一个全局的objct对象.

2.2 synchronized底层原理

2.1 字节码层面的锁标志

jvm怎么知道加了synchronized就要加锁呢? 其实, 是在字节码层面加了特殊标志.

1. 同步代码块的字节码

public void decrStock() {
        synchronized (object) {
            -- stock;
            if (stock <= 0) {
                System.out.println("库存售罄");
                return;
            }
        }
    }

我们来看看这段代码的字节码文件, 被翻译成字节码的时候, 会被翻译成特殊的指令

加锁的时候, 增加了monitorenter关键字

然后在后面会有monitorexit, 进行退出.

为什么会有三个monitorexit呢? 这是因为进行了异常情况的捕获处理.

2. 同步类方法的字节代码

public static synchronized void decrStock() {
        System.out.println(--stock);
}

我们来看看被synchronized修饰的静态方法的字节码

我们看到, 在静态方法里面增加了ACC_SYNCHRONIZED标志. 这个标志有点类似与volatile的特殊标志. 有这个标志的代码, 及表示是同步代码块.

当jvm执行代码的时候, 遇到ACC_SYNCHRONIZED标识符, 在底层会自动加上monitorenter和monitorexit.

3. Monitor监视器锁

任何一个对象都有一个Monitor与之关联,当且一个Monitor被持有后,它将处于锁定状态。

Synchronized在JVM里的实现都是基于进入和退出Monitor对象来实现方法同步和代码块同步,虽然具体实现细节不一样,但是都可以通过成对的MonitorEnter和MonitorExit指令来实现。

  • monitorenter:每个对象都是一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
    1. 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
    2. 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
    3. 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;
  • monitorexit:执行monitorexit的线程必须是objectref所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

monitorexit,指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁;

  通过上面两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成其实wait/notify等方法依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

看一个同步方法:

package it.yg.juc.sync;
public class SynchronizedMethod {
    public synchronized void method() {
        System.out.println("Hello World!");
    }
}

反编译结果:

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

  当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。

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

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

  两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。

4. 什么是monitor?

可以把它理解为 一个同步工具,也可以描述为 一种同步机制,它通常被 描述为一个对象。与一切皆对象一样,所有的Java对象是天生的Monitor每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。也就是通常说Synchronized的对象锁MarkWord锁标识位为10,其中指针指向的是Monitor对象的起始地址。在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的):

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; // 记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; // 处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象 ),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时:

  1. 首先会进入 _EntryList 集合,当线程获取到对象的monitor后,进入 _Owner区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1;
  2. 若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒;
  3. 若当前线程执行完毕,也将释放monitor(锁)并复位count的值,以便其他线程进入获取monitor(锁);

Monitor对象存在于每个Java对象对象头Mark Word中(存储的指针的指向),Synchronized锁便是通过这种方式获取锁的也是为什么Java中任意对象可以作为锁的原因

同时notify/notifyAll/wait等方法会使用到Monitor锁对象所以必须在同步代码块中使用

监视器Monitor有两种同步方式:互斥协作

多线程环境下线程之间如果需要共享数据,需要解决互斥访问数据的问题,监视器可以确保监视器上的数据在同一时刻只会有一个线程在访问。

5. 那么有个问题来了,我们知道synchronized加锁加在对象上,对象是如何记录锁状态的呢?

答案是锁状态是被记录在每个对象的对象头(Mark Word)中,下面认识一下对象的内存布局

6. 对象的内存布局

HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)实例数据(Instance Data)对齐填充(Padding)

  • 对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象)等。

Java对象头一般占有2个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit,在64位虚拟机中,1个机器码是8个字节,也就是64bit),但是 如果对象是数组类型,则需要3个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

  • 实例数据:存放类的属性数据信息,包括父类的属性信息;
  • 对齐填充:由于虚拟机要求 对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐;

1) 对象头

HotSpot虚拟机的对象头包括三部分信息

    • 第一部分是“Mark Word”

用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志线程持有的锁、偏向线程ID、偏向时间戳等等,它是实现轻量级锁偏向锁关键

这部分数据的长度在32位和64位的虚拟机(暂 不考虑开启压缩指针的场景)中分别为32个和64个Bits,官方称它为“Mark Word”。

对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额 外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间

例如在32位的HotSpot虚拟机 中对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于存储对象分代年龄,2Bits用于存储锁标志位,1Bit固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下表所示。

但是如果对象是数组类型,则需要三个机器码因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化。

      变化状态如下:

32位虚拟机

锁状态

25bit

4bit

1bit

2bit

23bit

2bit

是否偏向锁(是否禁用偏向)

锁标志位

无锁态

对象的hashCode

分代年龄

0

01

轻量级锁

指向栈中锁记录的指针

00

重量级锁

指向Monitor的指针

10

GC标记

11

偏向锁

线程ID

Epoch

分代年龄

1

01

      • 如果最后两位的锁标志位是01, 则锁的状态只可能是无锁态偏向锁.
      • 如果是00: 就只能是轻量级锁
      • 如果是10: 就是重量级锁
      • 如果是11: 就是GC标志

        有1bit来标记是否是偏向锁. 如果是0 , 也就是后三位是001, 表示的是无锁态; 如果是101, 表示的是偏向锁.

64位虚拟机

现在我们虚拟机基本是64位的,而64位的对象头有点浪费空间, JVM默认会开启指针压缩,所以基本上也是按32位的形式记录对象头的。

      手动设置-XX:+UseCompressedOops
    • 第二部分: “Mark Word”
    • 第三部分:

7. 验证无锁状态的mark word存储内容

首先, 引入一个依赖, 打印对象的头信息

<dependency>
        <groupId>org.openjdk.jol</groupId>
        <artifactId>jol-core</artifactId>
        <version>0.10</version>
</dependency>

然后运行代码, 就可以大运出对象的头信息了

package com.example.tulingcourse.chapter5;

public class ObjectSize {

    public static void main(String[] args) {
        Object o = new Object();
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
    }
}

备注: 我在打印的时候, 并没有成功, jar包直接引入是引入不进来的, 手动引入的jar包. 但是没有ClassLayout这个类

接下来看看这段代码的头信息

这里的第一条就是mark word的信息

在操作系统里面, 采用的是高位和低位. 这里采用的是低位, 需要将参数反过来

没有加锁
00000001 00000000 00000000 00000000

反过来看, 才是mark word的顺序
00000000 00000000 00000000 00000001

我们对照表格, 最后三位刚好是001

也就是说, 当对象没有任何锁的时候, 最后三位是001, 但是往前的29位存储的是hashCode和分代年龄

但这里全部都是0 , 好像并没有存储hashCode , 这是什么原因呢?

原因是对象在初始化加载hashCode的时候, 使用了类似懒加载的方式, 使用的时候才会去加载

8. 无锁升级为偏向锁的mark word内存存储

偏向锁的启动有一个延迟加载时间, 时间为4s. 为什么偏向锁会延迟加载呢? 这是因为, jvm在启动的时候,本身会启动好多个线程, 这些线程之间也会有锁竞争, 等待其他初始化线程的锁竞争稳定后, 启动偏向锁. 可以验证偏向锁的延迟加载.

比如下面这段代码:

public static void main(String[] args) {
        Object o = new Object();
        System.out.println(ClassLayout.parseInstance(o).toPrintable());

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

启动这个主线程, 当只有一个主线程启动的时候, 不存在竞争, 所以, 这时候会由无锁升级为一个偏向锁, 我们来看看内存结构. 偏向锁的锁标志位是101, 我们来看看是不是101.

如上面代码, 一共打印了两次内存空间. 我们看看, 两次的锁状态分别是

第一次
00000001 00000000 00000000 00000000

第二次
11101000 11110010 10011001 00000010

根据地位原则, 反过来看也不是101呀, 先打印的是01无锁, 再打印的是00轻量级锁. 这是怎么回事呢? 难道直接从无锁状态升级到轻量级锁了么?

这就是上面说的问题, jvm启动的时候会延迟启动偏向锁. 延迟多久呢, 大概是4s的时间.

为什么这样呢?

因为jvm启动的时候, 会依赖大量的hashmap, class, 各种对象, 这些对象里面也存在大量的同步块. 而且, jvm启动的时候其内部也会启动线程. 这十几个线程也会产生竞争. 所以, jvm为了避免偏向锁向轻量级锁, 再向重量级锁升级的过程, 为了减少锁升级带来的开销, 所以, 把偏向锁推迟启动了.

偏向锁有一个4s的时间延迟, 所以, 这里没有显示偏向锁.

我们在代码上加上5秒钟的延迟, 再来看看

public static void main(String[] args) throws InterruptedException 
{
    Thread.sleep(5000);
    Object o = new Object();
    System.out.println(ClassLayout.parseInstance(o).toPrintable());

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

我们把打印的内存信息提取出来

第一次:
00000101 00000000 00000000 00000000 

第二次
00000101 01000000 000100001 00000011

根据低位原则, 反过来看. 那么第一次打印的是101偏向锁, 第二次打印的是101偏向锁. 我们来和之前没有锁的情况进行对比一下

未启动偏向锁, 没加锁
00000001 00000000 00000000 00000000

开启偏向锁
第一次: 无锁状态(匿名偏向,可偏向状态)
00000101 00000000 00000000 00000000 

第二次: 有同步块
00000101 01000000 000100001 00000011

我们发现, 第一次没有锁的时候, 我们锁状态时01

在第二次, 加了偏向锁, 打印了两次内存, 第一次是在同步代码块以外, 这时候打印的锁状态是101偏向锁, 难道说, 启动了偏向锁以后, 我的对象没有加任何同步块, 也会加一个锁么?

通过观察, 我们发现, 这个时候, 锁的状态是101 ,但是其后面的状态码都是0, 这时候其实是无锁状态, 同时是匿名偏向, 可偏向状态.

看这个图, 偏向锁的钱23位本应该记录的是线程ID, 但是, 这里全部都是0, 这表示, 我当前是一个偏向锁, 但是还没有偏向于任何线程. 后面哪个线程抢到了偏向锁, 线程ID就记录哪个线程的.

我们再来看第二个

第二次: 有同步块
00000101 01000000 000100001 00000011

换算成大端

00000011 00010001 01000000 00000101

低3位是101, 高23位是线程ID: 00000011 00010001 0100000将其换算为10进制, 就是线程id了

9. 偏向锁升级为轻量级锁

public class BasicLock {

    public static void main(String[] args) throws InterruptedException {
        Thread.sleep(5000);

        Object o = new Object();
        Log.info(ClassLayout.parseInstance(o).toPrintable());

        Thread t1 = new Thread(() -> {
            synchronized (o) {
                Log.info(ClassLayout.parseInstance(o).toPrintable());
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (o) {
                Log.info(ClassLayout.parseInstance(o).toPrintable());
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        t1.start();
        t2.start();

        Thread.sleep(2000);

这里一共打印了四次sysout

其中, 头两次高位排序后, 仍然是101. 其中第一个是匿名偏向. 第二个记录了线程ID

再看看打印的第二条和第三条数据. 看高位排序后的后两位, 知道他是00, 00表示的是轻量级锁. 也就是从偏向锁向轻量级锁转换了.

为什么会从偏向锁向轻量级锁转换呢? 原因是, 整个程序是从上往下执行的, 上面执行完了, 才执行下面的进程. 有两个线程竞争同时去调用共享资源o, 但他们的竞争不激烈 所以, 会将偏向锁向轻量级锁升级.

11. 问题

一个偏向锁在调用hashcode以后会升级为一个轻量级锁

10. 轻量级锁升级为重量级锁

 public class BasicLock {

    public static void main(String[] args) throws InterruptedException {
        Thread.sleep(5000);

        Object o = new Object();
        Log.info(ClassLayout.parseInstance(o).toPrintable());

        Thread t1 = new Thread(() -> {
            synchronized (o) {
                Log.info(ClassLayout.parseInstance(o).toPrintable());
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        Thread t2 = new Thread(() -> {
            synchronized (o) {
                Log.info(ClassLayout.parseInstance(o).toPrintable());
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        t1.start();
        t2.start();


        Thread.sleep(2000);


    }

}

重量级锁, 什么时候升级为重量级锁呢? 在轻量级锁内部有一个while循环, 加入200次, 如果200还没有释放锁, 就是重量级锁. 我们通过Thread.sleep()来模拟

我们看到地位的最后两位是10. 通过查看上图, 我们知道10表示的商量级锁.

哪些信息会被压缩?

1.对象的全局静态变量(即类属性)

2.对象头信息:64位平台下,原生对象头大小为16字节,压缩后为12字节

3.对象的引用类型:64位平台下,引用类型本身大小为8字节,压缩后为4字节

4.对象数组类型:64位平台下,数组类型本身大小为24字节,压缩后16字节

在Scott oaks写的《java性能权威指南》第八章8.22节提到了当heap size堆内存大于32GB是用不了压缩指针的,对象引用会额外占用20%左右的堆空间,也就意味着要38GB的内存才相当于开启了指针压缩的32GB堆空间。

这是为什么呢?看下面引用中的红字(来自openjdk wiki:https://wiki.openjdk.java.net/display/HotSpot/CompressedOops)。32bit最大寻址空间是4GB,开启了压缩指针之后呢,一个地址寻址不再是1byte,而是8byte,因为不管是32bit的机器还是64bit的机器,java对象都是8byte对齐的,而类是java中的基本单位,对应的堆内存中都是一个一个的对象。

Compressed oops represent managed pointers (in many but not all places in the JVM) as 32-bit values which must be scaled by a factor of 8 and added to a 64-bit base address to find the object they refer to. This allows applications to address up to four billion objects (not bytes), or a heap size of up to about 32Gb. At the same time, data structure compactness is competitive with ILP32 mode.

对象头分析工具

运行时对象头锁状态分析工具JOL,他是OpenJDK开源工具包,引入下方maven依赖

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.10</version>
</dependency>

打印markword

System.out.println(ClassLayout.parseInstance(object).toPrintable());
object为我们的锁对象

synchronized是基于JVM内置锁实现,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁, 性能较低。当然,JVM内置锁在1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销,,内置锁的并发性能已经基本与Lock持平。

synchronized关键字被编译成字节码后会被翻译成monitorenter 和 monitorexit 两条指令分别在同步块逻辑代码的起始位置与结束位置。

每个同步对象都有一个自己的Monitor(监视器锁),加锁过程如下图所示:

锁的膨胀升级过程

锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。从JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking来禁用偏向锁。下图为锁的升级全过程:

偏向锁

偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。下面我们接着了解轻量级锁。

默认开启偏向锁

开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0

关闭偏向锁:-XX:-UseBiasedLocking

轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

自旋锁

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。

锁消除

消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。锁消除的依据是逃逸分析的数据支持。

锁消除,前提是java必须运行在server模式(server模式会比client模式作更多的优化),同时必须开启逃逸分析

:-XX:+DoEscapeAnalysis 开启逃逸分析

-XX:+EliminateLocks 表示开启锁消除。