java对象系列

230 阅读10分钟

对象内存布局

  • 对象组成

Hotspt 采用了 OOP-Klass 模型。 它是描述 java 对象实例的模型,可分为两部分:

  • OOP (Ordinary Object Pointer)指的是普通对象指针,它包含 MarkWord 和Klass 指针。MarkWord 用于存储当前对象运行时的一些状态数据;Klass 指针则指向 Klass,用来告诉当前指针指向的对象是什么类型,即对象是使用哪个类创建出来的
  • 之所以采用这种一分为二的对象模型,是因为 hotspot jvm 的设计者不想让每个对象中都包含一个 virtual table (虚函数表), 所以把对象模型拆成 klass 和 oop,其中 oop 不包含任何虚函数,而 klass 含有虚函数表,可以进行method dispatch

1589643623482

java对象分为两种,一种是普通类型,一种是数组对象,如上图所示。在这里我们将会结合具体的例子来具体分析。

  1. 打印JVM参数:-XX:+PrintCommandLineFlags
1
-XX:InitialHeapSize=126504128 -XX:MaxHeapSize=2024066048 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC
  • 注意UseCompressedOops和UseCompressedClassPointers参数
  1. 引入OpenJDK所提供的JOL包
12345
<dependency>         <groupId>org.openjdk.jol</groupId>         <artifactId>jol-core</artifactId>         <version>0.10</version>     </dependency>
  1. 调用代码
12345678910
public class App {    public static void main( String[] args )    {        Object o = new Object();        //解析对象        String s=ClassLayout.parseInstance(o).toPrintable();        System.out.println( s);    }}
  1. 分析输出

1589641747476

本机测试win64位,所以默认对齐8,因此这里总字节数为16直接。

举一反三,那这个呢?

1234567891011
public class Student {    String name; 
   Long num;    public static void main(String[] args) {       
 Student o = new Student();        //解析对象        
String s=ClassLayout.parseInstance(o).toPrintable();       
 System.out.println( s);    }}

分析

对象头8个字节+类型指针4个字节+Int4个字节+String4个字节=20个字节+4个对齐=24个字节

1589642197409

对象头

对象头主要有两部分(数组对象有三组分)组成。 Markword, Klass 指针(数组对象的话,还有一个 length)。

markword

markword主要存储对象运行的一部分数据。主要内容有 hashcode,GC 分代年龄,锁状态标志位(这个很重要),线程锁标记,偏向线程ID,偏向时间戳等。MarkWord 在32位和64位虚拟机上的大小分别位32bit 和 64bit,它的最后 2 bit 是锁标志位,用来标记当前对象的状态,具体如下:

状态标志位存储内容
未锁定01对象哈希码/对象分代年龄
轻量级锁定00指向锁记录的指针
膨胀(重量级锁定)10执行重量级锁定的锁指针
GC 标记11空(不需要记录信息)
可偏向01偏向线程id, 偏向时间戳,对象分代年龄

32 位 vm 在不同状态下 Markword 结构:

img

64位vm不同状态下的Markword结构

1589650698264

  • 31位的hashcode实际上为对象的identityHashCode值,只有需要的时候才会进行计算。
  • 分代年龄:最高为15,用于垃圾回收用的
  • 当前线程指针实际上是线程ID
  • 指向线程栈中的LockRecord:此时这个不再是线程ID,他主要是考虑线程的可重入性。

synchronized实际过程

实际上通过synchronized给对象上锁,就是在对象头上锁。附上网上一张牛皮的流程图www.zhihu.com/question/53…

12

无锁—–>[偏向锁可选]—–>轻量级自旋锁——>重量级锁

简化版

1589650399071

  1. 无锁状态或者匿名偏向锁
  • 无锁状态,是因为偏向锁有个延迟启动时间

1589649723288

  • 匿名偏向锁:是指对象刚创建,还没有偏向任何一方
  • 无锁—>升级为轻量级锁
123456789101112131415161718
public class Student {    String name;    Long num;    public static void main(String[] args) throws InterruptedException {               Student o = new Student();        //解析对象        String s=ClassLayout.parseInstance(o).toPrintable();        System.out.println( s);        synchronized (o){            String s=ClassLayout.parseInstance(o).toPrintable();            System.out.println( s);        }    }}

1589650316722

  1. 偏向锁

在JDK中有许多方法是synchronized修饰的,比如StringBuffer,但是程序大多时候仅仅只有一个线程执行,此时我们只需要将线程ID记录到markword里面,不需要惊动os这种大佬。

  • 调用实例
12345678910
public static void main(String[] args) {       Student o = new Student();       //解析对象       String s=ClassLayout.parseInstance(o).toPrintable();       System.out.println( s);       synchronized (o){           s=ClassLayout.parseInstance(o).toPrintable();           System.out.println( s);       }   }
  • 看看对象头的变化

1589645919881

  • 默认情况下,偏向锁有个时延(默认为4s),由参数-XX:+BiasedLockingstartupDelay设置。为啥呢?因为JVM虚拟机自己有一些默认启动的线程,内部有好多sync代码,这些sync代码友尽整,如果使用偏向锁,就会造成偏向锁不断的进行锁车小何锁升级操作,效率较低。

    12345678910111213141516171819
    # 参数打印我们来看看C:\Users\xiaxuefei>java -XX:+PrintFlagsFinal -version | findstr  Biased*   intx BiasedLockingBulkRebiasThreshold          = 20        {product}   intx BiasedLockingBulkRevokeThreshold          = 40        {product}   intx BiasedLockingDecayTime                    = 25000        {product}   intx BiasedLockingStartupDelay                 = 4000        {product}   bool TraceBiasedLocking                        = false        {product}   bool UseBiasedLocking                          = true        {product}java version "1.8.0_181"Java(TM) SE Runtime Environment (build 1.8.0_181-b13)Java HotSpot(TM) 64-Bit Server VM (build 25.181-b13, mixed mode)-XX:+BiasedLockingstartupDelay=0

    由于偏向锁启动需要一定的时间,默认为4s,因此我们最开始的对象应该是无锁的,markword对应位001

    1589649684163

    验证了一下果然是的。那我们继续验证偏向锁(其实此时为匿名偏向锁)。

    123456789101112131415
    public class Student {    String name;    Long num;    public static void main(String[] args) throws InterruptedException {        //先睡眠5s        TimeUnit.SECONDS.sleep(5);        Student o = new Student();        //解析对象        String s=ClassLayout.parseInstance(o).toPrintable();        System.out.println( s);    }}

    看看结果,发现变成了101,这个不就是偏向锁了吗?
    1589649859775

  1. 自旋锁

自旋锁是指,现在不止一个线程来争抢了(就比如你的女神,我擦咧,好小子居然还有人在追),此时我们依旧没必要通知os这种大佬级别的,而是通过自旋锁(CAS)来争抢是否能把自己的信息写对象的markword里。

  1. 重量级锁

如果线程比较多,想想如果有1000个线程,999个都在那里自旋消耗CPU,肯定代价很大,此时我们就需要os大佬的参与,此时对于没抢到的会进入同步队列。

JDK1.6之前自旋锁升级为重量级有两个条件:自旋次数(默认10次)和竞争线程数(默认为5个),但是JDK1.6以后不需要调节这两个参数,而是启用了自适应自旋锁,他内部会自动计算什么时候,自旋锁升级成重量级锁。

对象定位

java对象访问有两种方式:句柄和直接指针

1589652280147

1589652437761

类型优点缺点
句柄(其实类似两级指针)进行GC时候不用频繁改动t需要两次寻址
直接指针寻址快不利于垃圾回收

对象分配

  1. 逃逸分析

逃逸分析是编译语言中的一种优化分析,而不是一种优化的手段。通过对对象的作用范围分析,为其优化手段提供分析数据从而进行优化。其实java以前没有在栈上分配的对象,后来借鉴C语言的思想,比如struct s,默认是在栈上分配的,因此引入了逃逸分析,来查看哪些对象可以在栈上分配。jvm默认是打开逃逸分析的。
逃逸分析包括:

  • 全局变量赋值逃逸
    • 对象是一个静态变量
    • 对象是一个已经发生逃逸的对象
  • 方法返回值逃逸
  • 实例引用发生逃逸
  • 线程逃逸:赋值给类变量或可以在其他线程中访问的实例变量.
1234567891011121314151617181920
public class EscapeAnalysis {     public static Object object;          public void globalVariableEscape(){//全局变量赋值逃逸           object =new Object();        }            public Object methodEscape(){  //方法返回值逃逸         return new Object();     }          public void instancePassEscape(){ //实例引用发生逃逸        this.speak(this);     }          public void speak(EscapeAnalysis escapeAnalysis){         System.out.println("Escape Hello");     }}

开启逃逸分析:-XX:+DoEscapeAnalysis
关闭逃逸分析:-XX:-DoEscapeAnalysis
显示分析结果:-XX:+PrintEscapeAnalysis

  1. 标量替换
  • 标量和聚合量

标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及reference类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一步分解的聚合量。

  • 替换过程

通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而会将该对象成员变量分解若干个被这个方法使用的成员变量所代替。这些代替的成员变量直接在栈帧或寄存器上分配空间。

通过-XX:+EliminateAllocations可以开启标量替换, -XX:+PrintEliminateAllocations查看标量替换情况

  1. 栈上分配

栈上分配的好处就是不需要进行GC。

  1. 锁消除

同步消除是java虚拟机提供的一种优化技术。通过逃逸分析,可以确定一个对象是否会被其他线程进行访问
如果对象没有出现线程逃逸,那该对象的读写就不会存在资源的竞争,不存在资源的竞争,则可以消除对该对象的同步锁。

通过-XX:+EliminateLocks可以开启同步消除,进行测试执行的效率

1589655023703

对象字节大小

在对象内存布局一章,我们知道Hotspt 采用了 OOP-Klass 模型,这里的OOP(Ordinary Object Pointer)指的是普通对象指针,占用4个字节。而且当时也是第一时间强调当时运行java实例时jvm的参数。即:

打印JVM参数:-XX:+PrintCommandLineFlags

1
-XX:InitialHeapSize=126504128 -XX:MaxHeapSize=2024066048 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:-UseLargePagesIndividualAllocation -XX:+UseParallelGC
  • InitialHeapSize:初始化内存大小,最好让他等于最大值,否则JVM会动态的在最小值和最大值之间修改,浪费系统资源。
  • MaxHeapSize:最大大小。
  • UseCompressedClassPointers:压缩类指针。64位机器,默认引用是占8个字节,开启压缩之后,就只占用4个字节。可以使用-XX:-UseCompressedClassPointers取消设置,进而来设置。
123456789101112131415
//-XX:+PrintCommandLineFlags  -XX:-UseCompressedOops  -XX:-seCompressedClassPointerspublic class Student {    String name;    Long num;    public static void main(String[] args) throws InterruptedException {        Student o = new Student();        //解析对象        String s = ClassLayout.parseInstance(o).toPrintable();        System.out.println(s);    }}

1589655879228

  • UseCompressedOops:压缩普通对象指针。这个意思是比如某个类中有个string对象,此时开启该选项之后,也只占用4个字节。可以使用-XX:-UseCompressedOops取消设置,进而来设置。

1589655980814

  • 思考一下:什么时候关闭压缩呢?

压缩内存是为了在堆内存中装更多的对象,4个字节寻址最大内存为32G,如果你的堆内存超过了,这么多,此时如果还是按照4个字节寻址,那有些对象无法访问到了。但是可能大内存装的对象没有原先的多。

gzh