Java内存布局
在HotSpot虚拟机中java对象的内存布局一般为对象头(mark word),data,padding,如果是数组对象那么对象头还有一个length属性。
Mark Word
Mark Word 一般存放两部分的信息,其中一个为Class Point 一个指针,jvm虚拟机通过该指针确定该对象具体是那个类的实例。另一部分存放信息比较繁杂,比如:哈希码,GC年龄,还有锁的状态标志,线程持有的锁,偏向线程的ID,偏向时间戳,在32位和64位的jvm虚拟机(未开启压缩指针)中分别为32bit和64bit。Mark Word的数据结构不是固定的,可以根据对象的状态复用自己的存储空间。以32bit的虚拟机为例结构如下:对象处于未锁定状态:25bit存储对象的哈希码,4bit存GC年龄,2bit存放锁的标志位,1bit固定为0;
Padding
默认情况下,Java 虚拟机堆中对象的起始地址需要对齐至 8 的倍数。如果一个对象用不到 8N 个字节,那么空白的那部分空间就浪费掉了。即对其填充,就是占位符的作用,对应虚拟机选项 -XX:ObjectAlignmentInBytes,默认值为 8 ; 填充用来内存对齐进而去寻址。这个对其填充的用处其实是为了让一个对象完整的分配在cpu的同一个缓存行中,如果没有填充区进行内存对齐,那样跨了缓存行去存取的不仅效率低下,还会污染两个缓存行。一般用一个long的实例变量来做例子(局部变量会被逃逸分析优化在栈上分配存储),开启指针压缩之后,long原本是8个字节,压缩之后变为4个字节,所消耗的应该是12个字节,这就会有4个字节在第二个内存对齐块中是释放出来的。如同下面的例子前12个字节被占满了。13,14,15,16释放出来了。
first memory padding area | second memory padding area |
---|---|
【1】【2】【3】【4】【5】【6】【7】【8】 | 【9】【10】【11】【12】13]]14]15]16] |
指针
指针部分要说的也就是压缩指针这个东西了
压缩指针
在64位的jvm中指针也是64位的,但是实际的heap中比如一个(4个字节Integer对象)的变量就额外浪费16个字节(64位的class point和 64位的mark word)。这就照成了内存的浪费,这也是java引入基本类型的的原因,局部变量的基本数据类型可以不分配在堆中。所以啊就要搞点事情优化优化。优化的实质呢其实也就能优化的是class point的大小,开启了指针压缩之后 指针的大小被优化为32位, 这样就优化了 4个字节,在64位的系统中。cpu访问内存的对象的时候必须是64位,java对象的内存地址也必须是64位的,但是java对象内部的访问则是必须通过jvm虚拟机,不管存还是读都又jvm来搞,那么当然可以jvm内部存的时候将64bit转换为32bit,在读取的时候几将32bit的转换为64bit的输出去访问。压缩存储规则X=(X+(JVMHeap)baseAddr)>>3
解压读取规则X= X<<3+(JVM Heap)baseAddr
开启压缩指针jvm参数(-XX:+UseCompressedOops),会压缩的对象:每个Class的属性指针(静态成员变量),每个对象的属性指针,普通对象数组的每个元素指针,但是比如指向jdk1.8之前的PermGen的Class对象指针,本地变量,堆栈元素,入参,返回值,NULL指针不会被压缩.
指针压缩默认开启在堆内存为4KB~32G以下时。当堆内存超过32G就会关闭压缩指针。当然关闭了压缩指针之后,内存依旧是对其的,只不过是在存储时会浪费部分的空间;
内存对齐不仅仅是对象之间,字段内也是存在的,其价值在于避免不同的字段出现在同一个cpu缓存行内因而造成的并发访问下的伪共享。JVM会做一个字段的重排序,主要干的工作呢就是,字段的内存分配不一定是按照代码书写顺序进行分配,JVM字段重排序拥有三种级别(-XX:FieldsAllocationStyle,默认值为 1).
这三种分配方式我们在关闭压缩指针的情况下通过jdb命令调试一下
0 引用类型在原始类型前面, 然后依次是longs/doubles, ints, shorts/chars, bytes, 最后是填充字段, 以满足对其要求.
1 引用在原始类型后面
2 JVM在布局时会尽量使父类对象和子对象相连这分配
public class Test{
public int id;
public long money;
public String name;
public static void main(String args[]){
Test test=new Test();
test.id=1;
test.money=1;
test.name="Mood";
int x=100;
int y=200;
int z=300;
}
}
首先要javaC编译时候选定输出调试信息
命令:javac -g Test.java
待编译通过之后,进行jdb断点调试,之前我们都在IDE中断点调试,几乎不会使用jdk所带工具的调试。今天我们就来试试。
命令: jdb -XX:+UseSerialGC -Xmx10M -XX:FieldsAllocationStyle=0 -XX:-UseCompressedOops Test
>正在初始化jdb...
>stop in Test:12 //该处命令的意思可以理解为我们在ide中给对应的行前加入断点
正在延迟断点Test:12。
将在加载类后设置。
>run //之前stop in设置了断点,这里的run命令的含义就是启动运行,当程序运行到断点处会输出详细的信息。
运行Test
设置未捕获的java.lang.Throwable
设置延迟的未捕获的java.lang.Throwable
>
VM 已启动: 设置延迟的断点Test:12
断点命中: "线程=main", Test.main(), 行=12 bci=31
12 int z=300;
main[1] _ //此处等待输入命令 如next进入下一行执行过,等等,在此处输入help可以显示命令列表。
现在让Test程序在这里挂起。我们重新开一个命令窗口区启动HSDB去查看运行信息。
HSDB是JDK自带的一个小工具位于jdk/lib目录下sa-jdi.jar,
启动命令:进入该目录执行java -cp sa-jdi.jar sun.jvm.hotspot.HSDB
启动之后呢用jps获取对应的pid,然后就可以查看Test的Stack的数据了
直接进入HSDB的Stack Memory View for main中 根据第三列的注释信息很容易可以得带实例变量test的内存空间。通过使用HSDB的Console我们可以查看到对应的信息,此处只展示几个,如有兴趣可以help自行研究,常使用的也就几个如 universe inspect mem whatis revptrs scanoops
hsdb>universe 可以显示堆的详细
Heap Parameters:
Gen 0: eden [0x0000000012600000,0x00000000126fea60,0x00000000128b0000) space capacity = 2818048, 37.012854287790695 used
from [0x00000000128b0000,0x00000000128b0000,0x0000000012900000) space capacity = 327680, 0.0 used
to [0x0000000012900000,0x0000000012900000,0x0000000012950000) space capacity = 327680, 0.0 usedInvocations: 0
Gen 1: old [0x0000000012950000,0x0000000012950000,0x0000000013000000) space capacity = 7012352, 0.0 usedInvocations: 0
hsdb> inspect 0x126f8610 显示这块地址的内容
instance of Oop for Test @ 0x00000000126f8610 @ 0x00000000126f8610 (size = 40)
_mark: 1
_metadata._klass: InstanceKlass for Test
id: 1
money: 1
name: "Mood" @ 0x00000000126f8638 Oop for java/lang/String @ 0x00000000126f8638
hsdb> mem 0x126f8610 10 显示这块地址开始,往后10个字款的信息
0x00000000126f8610: 0x0000000000000001 //_mark
0x00000000126f8618: 0x00000000134003c0 //_metadata._klass
0x00000000126f8620: 0x00000000126f8638
0x00000000126f8628: 0x0000000000000001
0x00000000126f8630: 0x0000000000000001
0x00000000126f8638: 0x0000000000000001
0x00000000126f8640: 0x0000000013009010
0x00000000126f8648: 0x00000000126f8658
0x00000000126f8650: 0x0000000000000000
0x00000000126f8658: 0x0000000000000001
hsdb>whatis 0x00000000126f8638 会告诉你他分配在那个位置(TLAB)
Address 0x00000000126f8638: In thread-local allocation buffer for thread "main" (4) [0x00000000126f0da0,0x00000000126f8678,0x00000000126fea48,{0x00000000126fea60})
hsdb> revptrs 0x00000000126f8610 会告诉你该段地址属于那个Class(反解地址)
Computing reverse pointers...
Done.
Oop for Test @ 0x00000000126f8610
hsdb> 0x0000000012600000 0x0000000012950000 Test
0x00000000126f8610 Test
hsdb> scanoops 命令用于查找某段内存区域内的Class的信息。所有的都会被列出来
从inspect
的信息就可以看出来这块地址的内容中展示对应我们文章开头那个图的信息;
展示mark是Mark Word ;_metadata._klass是指针,指向对应是那个类的实例,剩余的 id moeny name皆是该对象的成员变量。
观察name变量可以看到其地址为0x00000000126f8638 ,从mem
和的信息中来看,他紧接着_metadata._klass的信息存放,并没有按我们的书写顺序布局。而是排在了首位。这里就印证了重排序的第一种模式。
紧接着我们来看第二种
inspect 0x126f8610
instance of Oop for Test @ 0x00000000126f8610 @ 0x00000000126f8610 (size = 40)
_mark: 1
_metadata._klass: InstanceKlass for Test
id: 1
money: 1
name: "Mood" @ 0x00000000126f8638 Oop for java/lang/String @ 0x00000000126f8638
hsdb> mem 0x126f8610 10
0x00000000126f8610: 0x0000000000000001
0x00000000126f8618: 0x00000000134003c0
0x00000000126f8620: 0x0000000000000001
0x00000000126f8628: 0x0000000000000001
0x00000000126f8630: 0x00000000126f8638
0x00000000126f8638: 0x0000000000000001
0x00000000126f8640: 0x0000000013009010
0x00000000126f8648: 0x00000000126f8658
0x00000000126f8650: 0x0000000000000000
0x00000000126f8658: 0x0000000000000001
选择第三种的时候
父类对象的地址完了接着的就是子类弟子的开始
提到字段的重排序就简述一下缓存行了,一个缓存行的大小为64字节,也就是说一次可以加载64字节的数据,合理的利用64字节,最省事的方式的独占就不让别人用,这样呢对于不够64字节的数据前一个字段就要补一些填充。因为如果两个volatile字段同处于一个缓存行的话就会出现了伪共享,这样在A线程访问完毕之后,B就需要刷新主存,同样那么再当A读取时候,优于可见性的存在发现被修改继续回去刷主存,在jdk1.8之前避免缓存行的处理方式可以是添加8个Long类型的变量,用于填充,让下面的变量独占一个缓冲行,1.8新增加一个@Contended注解可以主导独占缓存行,让这个注解生效的前提是启动时候需要增加-XX:-RestrictContended参数。对齐填充的方式在jdk1.8依旧是适用的。
public class Demo{
@sun.misc.Contended("field")
int field
}
可见性happens-before原则
现代计算机的CPU呢一般会有三级缓存分别为L1,L2,L3 ;其中L1直接和cpu交换数据,数据访存关系为
CPU<=>L1<=>L2<=>L3 ;cpu访问数据会优先从L1缓存中读取,取不到再去L2.接着L3去找,再找不到就发生了 缓存行Miss事件,要去主存中区拉取,这样的代价就是程序的处理效率会变得非常低下,想想redis被击穿直接查库的代价。扩展到多核CPU并发访问的时候在一个缓存行的数据发生改变,另一个CPU过来就会发现他所持有的缓存和原来的情况不一致,就会导致其去主存拉取。这个由缓存行伪共享导致的竞态问题就引发了不同CPU间缓存不一致,这个呢就是可见性问题
happens-before
原则是JMM为了处理并发访问时候出现的竞态数据的可见性,如X线程,Y线程同时访问m变量,如果X happens-before Y,那么X对M的写操作,Y是可见的。并且具备传递性,如果 Y线程 happens-before Z线程,那么Z对M的结果也是可见的。
happens-before :
1.volatile
修饰的字段
2.A线程的执行B线程的start()方法是,A修改的共享变量对B线程可见
3.B线程执行Thread.join();挂起方法,A线程对于B在等待join方法期间的变量修改可见
4.某线程对其它线程的中断方法调用,其它线程可见
5.构造器中的最后一个操作对开始的执行可见
6.解锁操作对加锁具有可见性
针对前面提到的 happens-before 关系他们会在会在编译的目标方法中插入相应的读读、读写、写读、写写内存屏障.
有序性指令重排序JMM内存屏障
当然我们知道CPU执行的时候不是独占形式的执行,而是时间片轮转的方式执行的,也就是CPU乱序执行,同时他对一些指令的执行或进行重排序执行,对于load -> read -> process -> save 的操作,多线程情况下那这个结果就可能不是最终的想要的结果了, 此外在Java中的即时编译器也会搞指令重排序。在并发程序中,如果任由指令重排序的话,那执行结果岂不是要爆炸,故而JMM呢就通过内存屏障来禁止指令重排序。volatile
的关键字插入的StoreLoad内存屏障会限制即时编译器的重排序操作。写写内存屏障不允许该字段写操作之前的内存访问被重排序至其之后;也将不允许该字段读操作之后的内存访问被重排序至其之前。
volatile
修饰的字段呢就会强制刷新缓存,写完数据之后直接同步到主存,同时会是其它持有缓存行的数据无效,从而通知其它线程区强制冲主存拉最新的数据,因此这就是被volatile
修饰的字段拥有可见性的原理,每次从主存取数据的效率我们前边也分析过,所以呢,对于volatile
修饰的字段呢最好是多读少写用作标记字段,最好只有一个线程写,其余都是读取。
final变量的写入对读取也是禁止重排序的,写入原理呢是即时编译器会在final域的写之后构造函数返回结果之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到这个构造函数方法之外 读取原理就是即时编译器编译器会在读final域操作的前面插入一个LoadLoad屏障。即时编译器通过这种方式来处理 final 实例字段则涉及新建对象的发布问题。当一个对象包含 final 实例字段时,我们希望其他线程只能看到已初始化的 final 实例字段。
在 X86_64 平台上,StoreStore屏障是空操作。StoreLoad屏障是有具体指令的。
原子性
jvm提供了Synchronized和Lock两个来控制原子性,反编译Synchronized的字节码可以看到是monitorenter和monotorexit,有这两条指令来处理方法级别或者代码块内的原子性,而Lock呢这是通过park和unpark来加锁
import sun.misc.Unsafe;
import java.lang.reflect.Field;
public class TestThread {
public static void main(String []args){
Unsafe unsafe=getUnsafe();
long time = System.currentTimeMillis()+3000;
Thread tm=Thread.currentThread();
new Thread( ()->{
System.out.println("结束终端");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread t=Thread.currentThread();
System.out.println("当前执行线程:"+t.getName());
unsafe.unpark(tm);
System.out.println("解锁主线程:"+tm.getName());
}).start();
System.out.println("主线程即将被加锁:"+tm.getName());
unsafe.park(true, time);
System.out.println("主线程继续执行:"+tm.getName());
System.out.println("线程结果:DONE");
}
public static Unsafe getUnsafe(){
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
Unsafe unsafe = (Unsafe)field.get(null);
return unsafe;
} catch (Exception e) {}
return null;
}
}
对于Synchronized的处理呢建议使用javap -v Test 来查看对用的信息
javap -v Test
...前面的常量池信息被我省略了
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: new #3 // class Test
3: dup
4: invokespecial #4 // Method "<init>":()V
7: astore_1
8: aload_1
9: dup
10: astore_2
11: monitorenter
12: aload_1
13: iconst_1
14: putfield #5 // Field id:I
17: aload_2
18: monitorexit
19: goto 27
22: astore_3
23: aload_2
24: monitorexit
25: aload_3
26: athrow
27: return
Exception table:
from to target type
12 19 22 any
22 25 22 any
LineNumberTable:
line 10: 0
line 11: 8
line 12: 12
line 13: 17
line 14: 27
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 22
locals = [ class "[Ljava/lang/String;", class Test, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
}
可以看到synchronized在java字节码层面的指令
对象的访问定位
目前主要是两种。一种就是Sun HotSpot中的直接指针访问,在栈中的本地变量表的引用存放的就是对象在Heap的地址,省去了指针定位的时间。对象创建频繁了也是一笔消耗。还有一种是使用句柄。使用句柄则是虚拟机会在堆内存中划分出两快空间一块是句柄池,一边是对象的实例数据,句柄池中存放了对象应用的和实力数据的对应信息,对象访问的时候先去句柄池,就是多一种指针定位的时间,这样做有个好处就GC的时候啊引用的地址不变,只改变实例数据的地址。
参考链接