1 cpu缓存相关
了解jvm内存模型前,先了解下cpu和计算机内存交互时使用的缓存。
高速缓存:
内存相当于cpu和磁盘之间的缓冲区,但是随着cpu的发展,内存的读写速度也远远赶不上cpu,加上高速缓存解决了处理器和内存一快一慢的矛盾。
缓存一致性问题:
在多核cpu中,每个处理器都有各自的高速缓存(L1,L2,L3),而主内存却只有一个,多线程场景下会存在缓存一致性问题。
内存屏障:
内存屏障是一种计算机指令,用于阻止屏障两侧指令重排序,并强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效。
volatile的特性:
volatile是Java中用于多线程安全的一个关键字,底层通过缓存一致性协议和内存屏障,实现了轻量级的多线程安全,提供可见性和部分原子性。可见性是指对于一个该变量的读,一定能看到读之前最后的写入;这里部分原子性是指对volatile变量的读写具有原子性,即单纯读和写的操作,都不会受到干扰,如a=3,但是对于a=b(先读后写)等复合操作无能为力。
2 Java内存区域
2.1 Java内存模型
Java内存模型是指jvm中一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的一套机制及规范。它有以下两个主要作用。
屏蔽各种差异:
屏蔽了各种硬件和操作系统的访问差异,不像c那样直接访问硬件内存,相对安全很多。
提供并发安全保证:
解决在多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。配合关键字等线程安全操作可以保证并发编程场景中的原子性、可见性和有序性。
2.2 五大数据区域
说到内存模型,先得了解jvm的内存分配和使用情况,它分为五大数据区域,线程共享的是方法区、堆,线程私有的是栈,本地方法栈和程序虚拟机。下面分别进行解释。
2.2.1 程序计数器
程序计数器是一块很小的内存空间,它是线程私有的,可以认作为当前线程的行号指示器。
指令串行执行
对于一个处理器(如果是多核cpu那就是一核),在一个确定的时刻都只会执行一个线程中的一个指令。
相互独立
为了线程切换可以恢复到正确执行位置,每个线程都需有独立的一个程序计数器,不同线程之间的程序计数器互不影响,独立存储
记录指令地址
如果线程执行的是个java方法,那么计数器记录虚拟机字节码指令的地址;如果为native【底层方法】,那么计数器为空。
异常情况
这块内存区域是虚拟机规范中唯一没有OutOfMemoryError的区域。
2.2.2 Java栈(虚拟机栈)
栈描述的是Java方法执行的内存模型,每创建一个线程,虚拟机就会为这个线程创建一个虚拟机栈,每一个方法被调用的过程就对应一个栈帧在虚拟机栈中从入栈到出栈(后入先出)的过程,方法调用时,创建栈帧,并压入虚拟机栈;方法执行完毕,栈帧出栈并被销毁。
栈帧是用来存储数据和部分过程结果的数据结构(局部变量表,操作数栈,动态链接,方法出口等),栈帧大小在编译期确定,不受运行期数据影响。
线程运行过程中,只有一个栈帧是处于活跃状态,称为“当前活跃栈帧”,当前活动栈帧始终是虚拟机栈的栈顶元素。
2.2.2.1 局部变量表
用来存放方法参数,以及方法内定义的局部变量,存放着编译期间已知的数据类型(boolean、byte、char、short、int、float、long、double、reference和returnAddress八种)。
reference类型虚拟机规范没有明确说明它的长度,但一般来说,虚拟机实现至少都应当能从此引用中直接或者间接地查找到对象在Java堆中的起始地址索引和方法区中的对象类型数据。
returnAddress类型是为字节码指令jsr、jsr_w和ret服务的,它指向了一条字节码指令的地址。
虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果是实例方法(非static),那么局部变量表的第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中通过this访问
Slot
最小的局部变量表空间单位,在jvm中,long和double类型数据明确规定为64位,这两个类型占2个Slot,其它基本类型固定占用1个Slot
2.2.2.1 操作数栈
操作数栈可理解为java虚拟机栈中的一个用于计算的临时数据存储区,和局部变量区一样,操作数栈也是被组织成一个以字长为单位的数组。但是和前者不同的是,它不是通过索引来访问,而是通过标准的栈操作(压栈和出栈)来访问的。
操作数栈可以把局部变量表里的数据、实例的字段等数据入栈;向其他方法传参的参数,也存在操作数栈中;其他方法返回的结果,返回时存在操作数栈中。下面是示例方法
public void plus(int a,int b){
int c=a+b;
int d=c+1;
System.out.println(d);
}
使用命令javac -verbose TestCompire.java和javac javap -verbose TestCompire.class查看,结果如下(注释是对部分操作数栈使用的说明)
public void plus(int, int);
descriptor: (II)V
flags: ACC_PUBLIC
Code:
stack=2, locals=5, args_size=3
0: iload_1 //加载局部变量表i1(index==1)进操作数栈
1: iload_2 //加载局部变量表i2(index==2)进操作数栈
2: iadd // 弹栈并将操作数栈的元素相加并将结果重新压入栈顶
3: istore_3 //将结果存到局部变量表i3
4: iload_3 //加载局部变量表i3(index==3)进操作数栈
5: iconst_1 //加载常量1进栈
6: iadd //弹栈并将操作数栈的元素相加并将结果重新压入栈顶
7: istore 4
9: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
12: iload 4
14: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
17: return
LineNumberTable:
line 3: 0
line 4: 4
line 5: 9
line 6: 17
}
2.2.2.1 动态链接
符号引用和直接引用在运行时进行解析和链接的过程,叫动态链接。
一个方法调用另一个方法,或者一个类使用另一个类的成员变量时, 需要知道其名字 ,符号引用就相当于名字,这些被调用者的名字就存放在Java字节码文件里(.class 文件)。
名字是知道了,但是Java真正运行起来的时候,如何靠这个名字(符号引用)找到相应的类和方法 。答案是需要解析成相应的直接引用,利用直接引用来准确地找到。
在类加载阶段中的解析阶段会将符号引用转为直接引用,这种转化也称为静态解析。
2.2.2.1 方法返回地址
指向一条字节码指令的地址。
当一个方法开始执行后,只有2种方式可以退出这个方法 :
方法返回指令 : 执行引擎遇到一个方法返回的字节码指令,这时候有可能会有返回值传递给上层的方法调用者,这种退出方式称为正常完成出口。
异常退出 : 在方法执行过程中遇到了异常,并且没有处理这个异常,就会导致方法退出。
无论采用任何退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息。
一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
2.2.2.1 两种异常
java.lang.StackOverflowError
线程请求的栈深度大于虚拟机允许的栈深度,将抛出StackOverflowError。
测试代码如下
//栈深度统计值
private int stackLength = 1;
/**
* 递归方法,导致栈深度过大异常
*/
public void stackLeak() {
stackLength++;
//注释代码
//int a=1024000000;
//int b=1024000000;
//int c=1024000000;
//int d=1024000000;
//int e=1024000000;
stackLeak();
}
/**
* 启动方法
* 测试结果:当-Xss为256k时,stackLength为7000左右,打开注释代码后为5000左右,每次运行都有所不同;随着-Xss参数变大,stackLength值随之变大
* @param args
*/
public static void main(String[] args) {
TestCompire demo = new TestCompire();
try {
demo.stackLeak();
} catch (Throwable e) {
System.out.println("当前栈深度:stackLength=" + demo.stackLength);
e.printStackTrace();
}
}
运行结果如下
当前栈深度:stackLength=7184
java.lang.StackOverflowError
at TestCompire.stackLeak(TestCompire.java:52)
at TestCompire.stackLeak(TestCompire.java:52)
at TestCompire.stackLeak(TestCompire.java:52)
at TestCompire.stackLeak(TestCompire.java:52)
java.lang.OutOfMemoryError: unable to create new native thread
如果虚拟机栈可以动态扩展(当前大部分的 Java 虚拟机都可动态扩展,只不过 Java 虚拟机规范中也允许固定长度的虚拟机栈),当扩展时无法申请到足够的内存时会抛出 OutOfMemoryError 异常。
测试代码如下
public void stackLeakByThread(){
while(true){
Thread thread = new Thread(new Runnable(){
@Override
public void run() {
while(true){
}
}
});
thread.start();
}
}
public static void main(String[] args) {
JavaVMStackOOM oom = new JavaVMStackOOM();
oom.stackLeakByThread();
}
运行结果如下
Exception in thread "main" java.lang.OutOfMemoryError: unable to create new native thread
at java.lang.Thread.start0(Native Method)
at java.lang.Thread.start(Thread.java:717)
at JavaVMStackOOM.stackLeakByThread(JavaVMStackOOM.java:25)
at JavaVMStackOOM.main(JavaVMStackOOM.java:31)
2.2.3 本地方法栈
与虚拟机栈相似,是虚拟机栈执行的是Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的native方法服务,可能底层调用的c或者c++。
虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError 和OutOfMemoryError 异常
2.2.4 堆
堆是java虚拟机管理内存最大的一块内存区域,因为堆存放的对象是线程共享的,所以多线程的时候也需要同步机制。同时它也是GC所管理的主要区域,因此常被称为GC堆。
2.2.4.1 存放内容
所有对象实例及数组都要在堆上分配内存,但随着JIT编译器的发展和逃逸分析技术的成熟,这个说法也不是那么绝对,但是大多数情况都是这样的
即时编译器
可以把Java的字节码(包括需要被解释的指令的程序)转换成可以直接发送给处理器的指令的程序。
在Java的编译体系中,一个Java的源代码文件变成计算机可执行的机器指令的过程中,需要经过两段编译,第一段是把.java文件转换成.class文件。第二段编译是把.class转换成机器指令的过程。
第一段编译就是javac命令。在第二编译阶段,JVM 通过解释字节码将其翻译成对应的机器指令,逐条读入,逐条解释翻译。很显然,经过解释执行,其执行速度必然会比可执行的二进制字节码程序慢很多,这就是传统的JVM的解释器(Interpreter)的功能。为了解决这种效率问题,引入了 JIT(即时编译) 技术。
引入了 JIT 技术后,Java程序还是通过解释器进行解释执行,当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。然后JIT会把部分“热点代码”翻译成本地机器相关的机器码,并进行优化,然后再把翻译后的机器码缓存起来,以备下次使用。
用户可以使用参数 -Xint 强制虚拟机运行于 “解释模式”(Interpreted Mode),这时候编译器完全不介入工作。另外,使用 -Xcomp 强制虚拟机运行于 “编译模式”(Compiled Mode),这时候将优先采用编译方式执行,但是解释器仍然要在编译无法进行的情况下接入执行过程。通过虚拟机 -version 命令可以查看当前默认的运行模式。
逃逸分析
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。
使用逃逸分析,编译器可以对代码做如下优化:
一、同步省略
在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。
如果同步块所使用的锁对象通过这种分析被证实只能够被一个线程访问,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这个取消同步的过程就叫同步省略,也叫锁消除。
二、标量替换
标量(Scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。
三、栈上分配
在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。
其实在现有的虚拟机中,并没有真正的实现栈上分配,对象没有在堆上分配,其实是标量替换实现的。
2.2.4.2 不连续,可扩展
根据虚拟机规范,Java堆可以存在物理上不连续的内存空间,就像磁盘空间只要逻辑是连续的即可。它的内存大小可以设为固定大小,也可以扩展。当前主流的虚拟机如HotPot都能按扩展实现(通过设置 -Xmx和-Xms)。
2.2.4.3 一种OutOfMemoryError,异常
如果堆中没有内存完成实例分配,而且堆无法扩展将报 java.lang.OutOfMemoryError: Java heap space错误。
测试代码如下
public static void main(String[] args) {
List<String> list=new ArrayList<>();
while(true){
list.add("fffffff");
}
}
运行结果如下
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3210)
at java.util.Arrays.copyOf(Arrays.java:3181)
at java.util.ArrayList.grow(ArrayList.java:265)
at java.util.ArrayList.ensureExplicitCapacity(ArrayList.java:239)
at java.util.ArrayList.ensureCapacityInternal(ArrayList.java:231)
at java.util.ArrayList.add(ArrayList.java:462)
at TestCompire.main(TestCompire.java:78)
2.2.5 方法区
方法区同堆一样,是所有线程共享的内存区域,方法区仅仅只是逻辑上的独立,实际上还是包含在Java堆中,为了区分堆,又被称为非堆
存放内容
已被虚拟机加载的类信息、常量、静态变量,如static修饰的变量加载类的时候就被加载到方法区中。
运行时常量池
是方法区的一部分,class文件除了有类的字段、接口、方法等描述信息之外,还有常量池用于存放编译期间生成的各种字面量和符号引用 。
自从JDK7之后,Hotspot虚拟机便将运行时常量池从永久代移到了在堆中。
永久代和元空间
永久代的情况:旧版本方法区放在了永久代里(HotSpot的设计),称为PermGen,一般很少被JVM进行回收。永久代和堆是相互隔离的,但它们使用的物理内存是连续的。同时,永久代的垃圾收集是和老年代捆绑在一起的,因此无论谁满了,都会触发永久代和老年代的垃圾收集。
移除永久代的努力:在JDK 7中开始进行移除永久代的努力,下面列出了JDK7中从永久带移除的东西:符号引用被移到了native堆;池化string对象被移到了java堆;Class对象、静态变量被移到了java堆。
废弃永久代:jdk8真正开始废弃永久代,但是并不是方法区就没了,方法区只是一个规范,只不过它的实现变了,PermSize和MaxPermSize参数也一并移除了,而使用元空间(Metaspace)。同时,元空间不再与堆连续,而且是存在于本地内存(Native memory)。这项工作是在这个bug:https://bugs.openjdk.java.net/browse/JDK-6964458推动下完成的。
本地内存(Native memory):也称为C-Heap,是供JVM自身进程使用的。当Java Heap空间不足时会触发GC,但Native memory空间不够却不会触发GC。
改换原因:
1、字符串存在永久代中,容易出现性能问题和内存溢出。
2、类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
3、永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
4、Oracle 可能会将HotSpot 与 JRockit 合二为一。JRockit从来没有所谓的永久代,也不需要开发运维人员设置永久代的大小,但是运行良好。
相关参数:默认情况下元空间是可以无限使用本地内存的,但为了不让它如此膨胀,JVM同样提供了参数来限制它使用的使用。
1、MetaspaceSize
metaspaceGC发生的初始阈值,也是最小阈值,默认20.8M左右,与之对比的主要是指Klass Metaspace与NoKlass Metaspace两块committed的内存和。
触发metaspaceGC的阈值是不断变化的:当metaspace使用的内存接近阈值时,会尝试增大阈值。metaspaceGC后也会调整阈值。
2、MaxMetaspaceSize
由于metaspace大部分在本地内存中分配,默认基本是无穷大,但仍然受本地内存大小的限制。为了防止metaspace被无止境使用,建议设置这个参数。
这个参数会限制metaspace(包括了Klass Metaspace以及NoKlass Metaspace)被committed的内存大小,会保证committed的内存不会超过这个值,一旦超过就会触发GC。
和MaxPermSize的区别,根据MaxMetaspaceSize,并不会在jvm启动的时候分配一块这么大的内存出来,而根据MaxPermSize分配的内存则是固定大小。
3、MinMetaspaceFreeRatio
MinMetaspaceFreeRatio和下面的MaxMetaspaceFreeRatio,主要是影响触发metaspaceGC的阈值。
默认40,表示每次GC完之后,如果metaspace内存的空闲比例小于MinMetaspaceFreeRatio%,那么将尝试做扩容,增大触发metaspaceGC的阈值。
不过这个增量至少是MinMetaspaceExpansion才会做,不然不会增加这个阈值。
这个参数主要是为了避免触发metaspaceGC的阈值和gc之后committed的内存的量比较接近,于是将这个阈值进行扩大。
注:这里不用gc之后used的量来算,主要是担心可能出现committed的量超过了触发metaspaceGC的阈值,这种情况一旦发生会很危险,会不断做gc,这应该是jdk8在某个版本之后才修复的bug
4、MaxMetaspaceFreeRatio
默认70,这个参数和上面的参数基本是相反的,是为了避免触发metaspaceGC的阈值过大,而想对这个值进行缩小。
这个参数在gc之后committed的内存比较小的时候并且离触发metaspaceGC的阈值比较远的时候,调整会比较明显。
一种OutOfMemoryError异常
如果方法区变得过大,将报 java.lang.OutOfMemoryError: Metaspace(jdk1.8之前是java.lang.OutOfMemoryError: PermGen space)错误。
测试代码如下
public class JavaMethodAreaOOM {
public static void main(String[] args) {
try {
while (true) {
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(JavaMethodAreaOOM.class);
enhancer.setUseCache(false);
enhancer.setCallback(new MethodInterceptor() {
public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
return -1;
}
});
enhancer.create();
}
}catch(Exception e){
e.printStackTrace();
}
}
jvm配置参数如下
-Xms5m -Xmx5m -XX:MetaspaceSize=5M -XX:MaxMetaspaceSize=7M (jdk1.8之前是-XX:PermSize=5M -XX:MaxPermSize=7M)
运行结果如下
net.sf.cglib.core.CodeGenerationException: java.lang.reflect.InvocationTargetException-->null
at net.sf.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:237)
at net.sf.cglib.proxy.Enhancer.createHelper(Enhancer.java:377)
at net.sf.cglib.proxy.Enhancer.create(Enhancer.java:285)
at JavaMethodAreaOOM.main(JavaMethodAreaOOM.java:31)
Caused by: java.lang.reflect.InvocationTargetException
at sun.reflect.GeneratedMethodAccessor1.invoke(Unknown Source)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at net.sf.cglib.core.ReflectUtils.defineClass(ReflectUtils.java:384)
at net.sf.cglib.core.AbstractClassGenerator.create(AbstractClassGenerator.java:219)
... 3 more
Caused by: java.lang.OutOfMemoryError: Metaspace
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
... 8 more
3 对象的内存布局
3.1 对象头
在32位系统下,对象头8字节,64位则是16个字节【未开启压缩指针,开启后12字节】
组成
锁状态、是否为偏向锁、锁标志位等
3.2 实例数据
存放对象程序中各种类型的字段类型,不管是从父类中继承下来的还是在子类中定义的
分配策略
相同宽度的字段总是放在一起,比如double和long
3.3 对齐填充
由于HotSpot规定对象的大小必须是8的整数倍,对象头刚好是整数倍,如果实例数据不是的话,就需要占位符对齐填充。
4 对象的访问定位
java程序需要通过引用(ref)数据来操作堆上面的对象,那么如何通过引用定位访问到对象的具体位置
句柄访问
简单来说就是java堆划出一块内存作为句柄池,引用中存储对象的句柄地址,句柄中包含对象实例数据、类型数据的地址信息。
引用中存储的是稳定的句柄地址,在对象被移动【垃圾收集时移动对象是常态】只需改变句柄中实例数据的指针,不需要改动引用【ref】本身。
直接指针
ref中直接存放对象的地址,但是类型数据跟句柄访问方式一样。
优势很明显,就是速度快,相比于句柄访问少了一次指针定位的开销时间。【可能是出于Java中对象的访问时十分频繁的,JVM HotSpot采用此种方式,但它需要额外的策略来存储对象在方法区中类信息的地址】