并发编程(2)—Java 对象内存布局及 synchornized 偏向锁、轻量级锁、重量级锁介绍

一、Java 对象内存布局

1、对象内存布局

一个对象在 Java 底层布局(右半部分是数组连续的地址空间),如下图示:
在这里插入图片描述

总共有三部分总成:

1. 对象头:储对象的元数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁等等。
2. 实例数据:存储对象实际的数据内容,即程序员定义的各种类型的变量。
3. 对其填充:为了 JVM 能够更快地访问对象内部的数据,会在实例数据后面填充额外的空间,使得对象的大小能够被虚拟机的内存管理系统所整除(一般都是8的倍数)。

具体对象头的大小和实例数据的大小,与 Java 虚拟机的具体实现、对象的类型、虚拟机运行时参数等都有关系,一般不是固定的数值。需要注意的是,数组对象与普通对象的内存布局是不一样的,数组对象会额外存储数组长度信息。

1.1、对象头

点击查看 hotspot 官网文档

(1) mark word 标记字

对象头两部分组成:

在这里插入图片描述
在这里插入图片描述

1. 对象标记(mark word):储对象的元数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁
2. 类元信息(klass pointer):存储的是指向该对象类元数据(klass)所在的首地址。

在 64位操作系统上,Markword 占了8个字节,类型指针占了8个字节,一共占16个字节。也就是说你随便 new 一个对象对象头就直接占了 16个字节(但是不一定,可能会压缩类型指针)。

在这里插入图片描述

(2) klass pointer 类型指针

可以参考下图,类型指针指向方法区,比如有个 Customer 类,new 一个 Customer 实例,这个实例的类型指针指向方法区中的 Customer 类元信息。

在这里插入图片描述

1.2、实例数据

存放类的 Field 数据信息,包括父类的属性信息;如果是数组实例部分,还需要包括数组的长度,这部分内存按照4个字节对齐。

举个例子如下:

public class MarkwordDemo {
    
    

    public static void main(String[] args) {
    
    
        new Apple();
    }
}
class Apple {
    
    
}

直接 new 一个空属性 Apple 实例,在内存中就已经占用16字节(不考虑类型指针压缩),如果 Apple 类中还有其他属性呢?如下所示:

public class MarkwordDemo {
    
    

    public static void main(String[] args) {
    
    
        new Apple();
    }
}
class Apple {
    
    
    int size = 100;
    char a = 'a';
}

一个 int 类型占 4个字节,一个 char 字符占1个字节,所以 new 一个 Apple 实例就会占用 16+5 = 21 个字节,但是最终会占用24个字节,因为 Java 底层为了方便内存管理,需要将其对齐填充,并且一般是8的倍数,所以是24字节。

1.3、对其填充

虚拟机要求对象起始地址必须是8字节的整数倍,填充数据不是必须存在的,仅仅是为了字节对齐,这部分内存按照8字节补充对齐。

二、同步锁底层探究

markOop.hpp 源码中有如下一段注释,如下图示:

在这里插入图片描述

把上述注释简化后,得到64位虚拟机对象头示意图,如下:

在这里插入图片描述

知道对象内部基本结构,那么下面来看看之前的 synchornized 同步锁在对象头中是怎么的变化过程。

1、 Java 查看对象内存布局

可以借助 Java 工具类 jol,帮助查看 new Object() 在内存中的布局创,如下所示:

1、先引入依赖

依赖包推荐使用 0.9 版本的,其他版本可能有不一样的效果,珍重。

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

2、演示代码

class MyObject {
    
    
}

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

直接 new class MyObject(),然后通过 ClassLayout 工具类查看内存布局,输出结果如下:

在这里插入图片描述

在这里插入图片描述

在 MyObject 类添加两个类型的变量,如下所示:

class MyObject {
    
    
	int i = 25;
	boolean flag = false;
}

public class ObjectMarkWordDemo {
    
    
    public static void main(String[] args) {
    
    
        System.out.println(ClassLayout.parseInstance(new MyObject()).toPrintable());
    }
}

然后输出之后的 Java 内存布局如下图示:

在这里插入图片描述

从上面可以看到,类型指针按理应该是占8个字节的,但现在是占用4个字节,我们可以通过命令查询 JVM 启动运行了哪些命令:

java -XX:+PrintCommandLineFlags -version

在这里插入图片描述

从上面 + 号就可以看出 JVM 默认采取类型指针压缩,可以节约内存空间,现在去修改一下这个参数设置,如下图示:

-XX:-UseCompressedClassPointers

在这里插入图片描述

开启之后,在重新测试下,输出结果如下:

在这里插入图片描述

学习上面已经知道怎么查看 Java 内存布局,现在再来学习一下,synchornized 锁优化 、锁升级相关。

2、synchornized 锁研究

在来看看对象头中 mardkword 标记字内存结构,如下图示:

在这里插入图片描述

synchornized 锁优化背景:

用锁能够实现安全性,但是也会带来性能的下降。无锁能够基于线程并提升程序性能,但是会带来安全性下降,那么怎么才能做到平衡呢?

所以在 jdk1.5开始就采取 synchornized 锁升级来提高程序性能,并且做到程序安全性。

在这里插入图片描述

在 jdk1.5 之前都是 synchornized 都是使用的操作系统重量级锁,每次上锁都需要进行用户态内核态之间的切换,切换的时候又伴随很多数据拷贝过程,性能很低。

在这里插入图片描述

Java 线程是映射到操作系统原生线程之上的,如果要阻塞或者唤醒一个线程就需要操作系统介入,需要在用户态和内核态之间切换,这种切换会消耗大量系统资源,因为用户态和内核态都有各自专用的内存空间,专用的寄存器等,用户态切换到内核态需要传递许多变量、参数给内核,内核也需要保存好用户态在切换映射的一些寄存器值、变量等,以便于内核态调用结束后切换回用户态继续工作。

在 Java 早期版本,synchornized 属于重量级锁,效率低下,因为监视器(Monitor)是依赖底层操作系统的 Mutex Lock 实现的,挂起和恢复线程都需转入内核态完成,阻塞或者唤醒一个 Java 线程需要操作系统切换 CPU 状态来完成,这种状态切换需要消耗 CPU 时间,如果通过代码块中内容过于简单,这种切换成本太高。

比如我们在代码块中加上 synchornized 关键字,代码如下:

class MyObject {
    
    
    int a = 25;
    char b = 'b';
}
public class ObjectMarkWordDemo {
    
    
    public static void main(String[] args) {
    
    
        MyObject myObject = new MyObject();

        new Thread(()->{
    
    
            synchronized (myObject) {
    
    
                System.out.println(">>>>>>");
            }
        }).start();
        
    }
}

在 Java 层面加上一个 synchronized 关键字,底层默认会加上一个看不见的锁—Monitor 锁,如下图示:

在这里插入图片描述

那么 Monitor 是如何与 Java 对象以及线程进行关联?

  1. 如果一个 Java 对象被某个线程锁住,该对象中的 markword 字段中的 lock word 会指向 Monitor 的起始地址。
  2. Monitor 的 Owner 字段会存放拥有相关联对象锁的线程 ID

3、锁优化过程

(1) 无锁

看下面这段代码没有加锁,如下所示:

public class ObjectMarkWordDemo {
    
    
    public static void main(String[] args) throws InterruptedException {
    
    
        Object abc = new Object();
        System.out.println(ClassLayout.parseInstance(abc).toPrintable());
    }
}

如果无锁,正常一个对象在 Java 内存中对象中的 markword 标记字,如下图示:

在这里插入图片描述

通过 Java 打印出信息如下:

在这里插入图片描述

注意上述展示的结果倒过来看,蓝色框框的001 表示此时无锁状态,在无锁状态时红色框框的31位表示 hashCode,其中一位是忽略补0。但是发现 hashCode 没有发现展现出来,是因为这个操作是懒加载,需要调方法才会触发 hashCode。例如下面代码:

public class ObjectMarkWordDemo {
    
    
    public static void main(String[] args) {
    
    
		MyObject myObject = new MyObject();
        System.out.println("十进制表示: myObject.hashCode() = " + myObject.hashCode());
        System.out.println("二进制表示:"+Integer.toBinaryString(myObject.hashCode()));
        System.out.println("十六进制表示:"+Integer.toHexString(myObject.hashCode()));
        System.out.println(ClassLayout.parseInstance(myObject).toPrintable());
    }
}

输出结果如下:

十进制表示: myObject.hashCode() = 1435804085
二进制表示:1010101100101001010000110110101
十六进制表示:5594a1b5

在这里插入图片描述

为了方便观察,把 hashCode 编码各个进制位打印出来。从右边往左开始拷贝(从右边往左边开始8个字节8个字节拷贝出来组成一个长串,前25位属于 unused 暂时不管它,后面31位属于 hashCode(蓝色框框的),红色框框3位表示锁相关,001 表示无锁状态)。第一拷贝:1010101(前面的0是补位忽略,不要拷贝,就是前面25位中 unused 的其中1位而已),第二个拷贝:10010100,第三个拷贝:10100001,第四个拷贝:10110101 然后连成一串就和上面打印出来的二级制(1010101100101001010000110110101)一模一样,这31位bit位就是存放的 hashCode。

从上面也可以看出,此时无锁状态时就是用 001 表示的。

(2) 偏向锁

当一段同步代码一直被同一个线程多次访问,由于只有一个线程那么该线程在后续访问时便会自动获得锁,如下图示(红色框):

在这里插入图片描述

只要哪个线程获取到偏向锁,就会把当前线程指针保存到这个对象的前54位中(无锁保存 hashCode 码),并且偏向锁位置成1,

为什么需要2个 bit 位表示锁标志位?
具体来说,synchronized 锁最开始是无锁状态,当第一个线程来竞争锁时,会将对象头中的锁标志位修改为偏向锁,然后将线程 ID 记录在对象头中,表示该线程获得了偏向锁。当第二个线程来竞争锁时,如果发现对象头中记录的线程 ID 和当前线程 ID 一致,那么它就可以获得锁,否则就需要撤销偏向锁,并转为轻量级锁状态。当有多个线程竞争锁时,就会进入到重量级锁状态。因此,为了实现锁升级过程,Java 在对象头中增加了两个 bit 位来表示锁标志位,以实现从无锁状态到偏向锁状态、再到轻量级锁状态、最后到重量级锁状态的转换过程。
这里有四种情况,所以刚好使用2bit表示上面出现的四种情况 。

假如有个线程执行到 synchornized 代码块时,JVM 使用 CAS 操作把线程指针 ID 记录到 mark word 中,并修改偏向锁位,标示当前线程获得到该锁。锁对象变成偏向锁(通过 CAS 修改对象头中的锁标志位)。执行完同步代码块之后,线程并不会主动释放偏向锁。

线程获得了锁,可以执行同步代码块。当线程2到达同步代码块时会判断此时持有锁的线程是否是自己,如果是自己的线程 ID,那说明还持有这个对象的锁,就可以继续执行同步代码块。由于之前没有主动去释放偏向锁,这里也就不需要重新加锁(不用重新去调用操作系统的 Mutex 上锁)。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎在这里没有额外开销,性能极高。

查询偏向锁是否开启

java -XX:+PrintFlagsInitial | grep BiasedLock

运行结果:

intx BiasedLockingBulkRebiasThreshold          = 20                                  {
    
    product}
intx BiasedLockingBulkRevokeThreshold          = 40                                  {
    
    product}
intx BiasedLockingDecayTime                    = 25000                               {
    
    product}
// 然后偏向锁开启之后默认会有4s钟的延迟,测试的时候需要注意,可以将这个值设置成0,方便查看效果
intx BiasedLockingStartupDelay                 = 4000                                {
    
    product}
bool TraceBiasedLocking                        = false                               {
    
    product}
// JVM 默认开启了偏向锁的设置
bool UseBiasedLocking                          = true                                {
    
    product}

开启偏向锁设置

-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0

通过展示结果 UseBiasedLocking = true 可以知道 JVM 默认是开启偏向锁,但是并不是程序启动就立即开启偏向锁,而是需要延迟 4s 后才会真正开启偏向锁。

为什么要延迟4s开启偏向锁?
由于偏向锁的获取需要一定的时间开销,因此JVM并不是在对象创建时立即开启偏向锁。相反,JVM在对象创建后,等待一定的时间(默认为4秒),以观察该对象的使用情况。如果在这段时间内,只有一个线程访问了该对象,那么JVM就会将对象的锁标志位设置为偏向锁,并将线程ID记录在对象头中,表示该线程已经获取了该对象的锁。如果在这段时间内,有多个线程访问了该对象,那么JVM就不会将对象的锁标志位设置为偏向锁,而是直接将锁标志位设置为轻量级锁或重量级锁,使用常规的加锁方式。

这种等待一定时间才开启偏向锁的策略,是为了避免在短时间内频繁创建和销毁对象,导致偏向锁的开销大于加锁的性能损耗。

演示效果是,需要将偏向锁延迟设置成0s,如图图示:

在这里插入图片描述
VM 中的命令如下:

-XX:BiasedLockingStartupDelay=0

演示代码如下:

public class ObjectMarkWordDemo {
    
    
    public static void main(String[] args) {
    
    

        Object abc = new Object();
        new Thread(() -> {
    
    
            synchronized (abc) {
    
    
                // 注意这里不要写任何代码操作
                System.out.println(ClassLayout.parseInstance(abc).toPrintable());
            }
        }).start();
    }
}

注意:在上述输出语句上不要写其他代码

输出结果如下:

在这里插入图片描述

可以对内布局中已经变为偏向锁 101。但是现在只是不存在锁竞争的情况下,如果一旦发现竞争就会进行锁撤销,去释放锁变成轻量级锁

(3) 偏向锁撤销

在这里插入图片描述

问题:偏向锁撤销带来性能严重下降?

偏向锁撤销是指在偏向锁状态下,由于竞争或者其他原因,需要将对象的锁状态恢复到无锁状态的过程。偏向锁撤销是指撤销偏向锁,恢复到无锁状态。

在偏向锁状态下,如果有其他线程尝试获取锁,则需要先撤销偏向锁。撤销偏向锁的过程需要检查对象的 hashCode 是否发生改变,如果 hashCode 发生改变,则需要撤销偏向锁,否则可以直接将锁升级为轻量级锁。在撤销偏向锁的过程中,需要重新偏向、清除偏向锁标志、设置线程 ID 为 0 等。

偏向锁撤销的过程是比较耗费性能的,因此需要尽量避免偏向锁撤销的情况发生,尤其是在高并发的场景下。

优化: 在竞争激励情况下,可以关闭偏向锁,直接升级到轻量级锁。

偏向锁撤销案例如下:

public class ObjectMarkWordDemo {
    
    
    public static void main(String[] args) throws InterruptedException {
    
    

        Object abc = new Object();

        synchronized (abc) {
    
    
            System.out.println("偏向锁:" + ClassLayout.parseInstance(abc).toPrintable());
        }
        System.out.println("偏向锁:" + ClassLayout.parseInstance(abc).toPrintable());
        new Thread(() -> {
    
    
            synchronized (abc) {
    
    	
            	System.out.println(">>>>>>发生竞争锁,触发偏向锁撤销...");
            }
        }).start();
        System.out.println("偏向锁撤销:" + ClassLayout.parseInstance(abc).toPrintable());
    }
}

在这里插入图片描述

(4) Lock Record

问题:什么是 Lock Record ?

在这里插入图片描述

线程A在运行期间会在栈帧里面创建一个空间,叫做 Lock Record 记录,用来存储锁记录。当虚拟机检测到这个对象是无锁状态时,就会在这个线程的栈帧上面创建一个这个空间,存储来自 Mark Word 锁相关信息。

Lock Record 里面的数据都是拷贝 Mark Word 里面的,因为锁的相关信息都是在 Mark Word 上面。同时,这个拷贝过程官方名为:Displaced Mark Word。最终通过 CAS 自旋操作,把这个栈帧的指针写到 Mark Word 中,写成功,表示这个线程A获取锁成功。写失败,表示这个锁被其他线程占用。

(5) 轻量级锁

轻量级锁为了在线程近乎交替执行同步代码时提高效率。

主要目的,在没有多线程竞争的前提下,通过 CAS 减少重量级锁使用操作系统互斥量产生的性能消耗,说白了先自旋在阻塞。升级时机,当关闭偏向锁功能或者多线程竞争偏向锁会导致升级为轻量级锁。

假如线程A已经拿到锁,这时候线程B又过来抢夺该对象的锁,由于该对象的锁已经被线程A拿到,当前该锁已经是偏向锁;而线程B在争夺发现 Makr Word 中线程ID不是自己的,那么线程B就会进入 CAS 自旋操作希望能够获取到锁。此时线程B操作中有两种情况:

①如果获取锁成功,直接替换 Mark Word 中的线程ID为线程B自己的ID,重新偏向于线程B,该锁保持偏向锁状态,A线程结束,B线程上位。

在这里插入图片描述

②如果获取锁失败,则偏向锁升级为轻量级锁,此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码块,而在竞争的线程B会进入自旋等待该轻量级锁。

在这里插入图片描述

轻量级锁自旋次数过多,造成CPU资源浪费,在 JDK6 之前默认自旋10次或者自旋线程数量超过 cpu 核数一半直接放弃自旋,升级为重量级锁 10

修改自旋次数命令:

-XX:PreBlockSpin=10

轻量级锁案例如下(可以把偏向锁延迟时间恢复):

-XX:BiasedLockingStartupDelay=4000
package com.xxl.job.admin.mytest;


import org.openjdk.jol.info.ClassLayout;

import java.util.concurrent.TimeUnit;

public class ObjectMarkWordDemo {
    
    

    public void test() {
    
    
        Object obj = new Object();
        synchronized (obj) {
    
    
            System.out.println("111");
        }
        synchronized (obj) {
    
    
            System.out.println("111");
        }
        synchronized (obj) {
    
    
            System.out.println("111");
        }
    }

    public static void main(String[] args) throws InterruptedException {
    
    
        // 打印 JVM 相关的信息
        // System.out.println(VM.current().details());
        // 打印每个对象是否为 8 的整数倍大小
        // System.out.println(VM.current().objectAlignment());
        MyObject myObject = new MyObject();
        System.out.println(Integer.toHexString(myObject.hashCode()));
        new Thread(()->{
    
    
            // 在 myObject 对象头上进行加锁(默认直接干到轻量级锁,这里我非要把他干到偏向锁状态)
            // 默认是开启偏向锁的,所以这里我们只需要把开启偏向锁的延迟时间修改成 0 方便看效果 -XX:+BiasedLockingStartupDelay=0
            synchronized (myObject) {
    
    
                // 给这个线程加锁,并且还设置了偏向线程 ID
                System.out.println(ClassLayout.parseInstance(myObject).toPrintable());
            }
        }).start();

        TimeUnit.MICROSECONDS.sleep(500);

        // 锁被释放了,所以这里打印的肯定是无锁状态 001
        System.out.println(ClassLayout.parseInstance(myObject).toPrintable());

    }
}
class MyObject {
    
    

}

运行结果:

76fb509a
# WARNING: Unable to attach Serviceability Agent. You can try again with escalated privileges. Two options: a) use -Djol.tryWithSudo=true to try with sudo; b) echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope
com.xxl.job.admin.mytest.MyObject object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           e8 29 c3 0a (11101000 00101001 11000011 00001010) (180562408)
      4     4        (object header)                           03 00 00 00 (00000011 00000000 00000000 00000000) (3)
      8     4        (object header)                           44 c1 00 f8 (01000100 11000001 00000000 11111000) (-134168252)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

# WARNING: Unable to attach Serviceability Agent. You can try again with escalated privileges. Two options: a) use -Djol.tryWithSudo=true to try with sudo; b) echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope
com.xxl.job.admin.mytest.MyObject object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           e8 29 c3 0a (11101000 00101001 11000011 00001010) (180562408)
      4     4        (object header)                           03 00 00 00 (00000011 00000000 00000000 00000000) (3)
      8     4        (object header)                           44 c1 00 f8 (01000100 11000001 00000000 11111000) (-134168252)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

(6) 重量级锁

当在轻量级锁一直自旋时,就需要考虑是不是用重量级锁还可以提高性能。

public class ObjectMarkWordDemo {
    
    
    public static void main(String[] args) throws InterruptedException {
    
    

        Object abc = new Object();
        for (int i = 0; i < 2; i++) {
    
    
            Thread thread = new Thread(() -> {
    
    
                synchronized (abc) {
    
    
                    System.out.println("重量级锁:" + ClassLayout.parseInstance(abc).toPrintable());
                }
            });
            thread.join();
            thread.start();
        }
    }
}

在这里插入图片描述

(7) 锁粗化

类似下面这个例子:

public class ObjectMarkWordDemo {
    
    
    Object abc = new Object();

    public  void show() {
    
    
        synchronized (abc) {
    
    
            // 复杂操作
        }
        synchronized (abc) {
    
    
            // 复杂操作
        }
        synchronized (abc) {
    
    
            // 复杂操作
        }
    }
}

使用的一直都是同一把锁,并且前后执行时间都非常短,JVM 会进行优化,将这些锁直接合并成为一个大的锁,可以称之为锁膨胀,提供程序性能。

(8) 锁消除

类似下面这个例子:

public class ObjectMarkWordDemo {
    
    

    public void show() {
    
    
        Object abc = new Object();
        synchronized (abc) {
    
    
            // 复杂操作
        }
    }
 }

show() 方法每次都会加锁,但是这个锁根本没有任何意义,所以 JVM 底层会把它优化掉提高程序性能。

三、常用命令

设置 JVM 堆大小

-Xms10m -Xmx10m

查询 JVM 启动运行了哪些命令

java -XX:+PrintCommandLineFlags -version

关闭对象头中类指针的压缩配置

-XX:-UseCompressedClassPointers

查询偏向锁是否开启

java -XX:+PrintFlagsInitial | grep BiasedLock

运行结果:

intx BiasedLockingBulkRebiasThreshold          = 20                                  {
    
    product}
intx BiasedLockingBulkRevokeThreshold          = 40                                  {
    
    product}
intx BiasedLockingDecayTime                    = 25000                               {
    
    product}
// 然后偏向锁开启之后默认会有4s钟的延迟,测试的时候需要注意,可以将这个值设置成0,方便查看效果
intx BiasedLockingStartupDelay                 = 4000                                {
    
    product}
bool TraceBiasedLocking                        = false                               {
    
    product}
// JVM 默认开启了偏向锁的设置
bool UseBiasedLocking                          = true                                {
    
    product}

开启偏向锁设置

-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0

猜你喜欢

转载自blog.csdn.net/qq_35971258/article/details/129286257