JVM-java中的虚拟机
第二章 jvm中的内存模型
文章目录
一、内存模型

其中红色是线程共有的,灰色是线程私有
程序计数器
用来存储指向下一条指令的地址,也即将要执行的指令代码。执行引擎的字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快 的存储区域。不会随着程序的运行需要更大的空间。
它是唯一一个在Java虚拟机规范中没有规定任何outothemoryError 情况的区域
栈
-Xss Isize (.: -XX:ThreadStackSize)设置大小
jdk5.0之前,默认栈大小:256k
jdk5.0之后,默认栈大小:1024k(linux\mac\windows)
栈桢(Stack Frame)
栈得单位是栈桢(Stack Frame)
方法和栈桢之间存在怎样的关系?
在这个线程上正在执行的每个方法都各自对应一个栈帧(StackFrame)
栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。

不同线程中所包含的栈帧是不允许存在相互引用的 ,即不可能在一个栈帧之中引用另外一个线程的栈帧。
Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。
内部结构
局部变量表
- 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型(8种)、对象引用(reference),以及returnAddress类型。
- 局部变量表所需的容量大小是在编译期确定下来的 ,并保存在方法的Code属性的maximum localvariables数据项中。在方法运行期间是不会改变局部变量表的大小的。
- 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间导致其嵌套调用次数就会减少。
- 局部变量表中的变量只在当前方法调用中有效 。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。

Slot - 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个Slot上
- 如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可。(比如:访问long或double类型变量)
- 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。
public String test2(Date dateP, String name2){
dateP = null;
name2 = "songhongkang";
double weight =130.5;//double 和 Long 会占据两个slot
char gender = '男';
return dateP + name2;
对应的Slot

操作数栈
操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
动态链接
- 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。比如:invokedynamic指令
- 在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(SymbolicReference)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
静态链接:
当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时 。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。
动态链接:
如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。
方法返回地址
一个方法的结束,有两种方式:
- 正常执行完成
- 出现未处理的异常,非正常退出
正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址 。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。
分配的栈内存越大越好吗?
不是,因为增加栈大小,会造成每个线程的栈都变的很大,使得一定的栈空间下,能创建的线程数量会变小
方法中定义的局部变量是否线程安全?
不一定,首先局部变量放在栈帧中,栈帧是线程私有的,目前来看是安全的,但是局部变量引用类型引用了堆中对象,堆中的对象不一定是安全的,堆是共享的
例子代码
java
public class LocalVariableThreadSafe {
// s1的声明方式是线程安全的, 因为线程私有, 在线程内创建的s1, 不会被其它线程调用
public static void method1() {
// StringBuilder: 线程不安全
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
// ...
}
// stringBuilder的操作过程: 是线程不安全的,
// 因为stringBuilder是外面传进来的, 有可能被多个线程调用
public static void method2(StringBuilder stringBuilder) {
stringBuilder.append("a");
stringBuilder.append("b");
// ...
}
// stringBuilder的操作: 是线程不安全的; 因为返回了一个stringBuilder,
// stringBuilder有可能被其他线程共享
public static StringBuilder method3() {
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("a");
stringBuilder.append("b");
return stringBuilder;
}
// stringBuilder的操作: 是线程安全的; 因为返回了一个stringBuilder.toString()相当于new了一个String,
// 所以stringBuilder没有被其他线程共享的可能
public static String method4() {
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("a");
stringBuilder.append("b");
return stringBuilder.toString();
}
/**
* 结论: 如果局部变量在内部产生并在内部消亡的, 那就是线程安全的
*/
}
方法区
栈 堆 方法区三者的联系

- 方法区(MethodArea)与Java堆一样,是各个线程共享的内存区域。
- 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。
- 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
- 关闭JVM就会释放这个区域的内存。
- 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutOfMemoryError:PermGen space(jdk7以前) 或者java.lang.OutOfMemoryError: Metaspace(jdk8以后)
加载大量的第三方的jar包;Tomcat部署的工程过多(30-50个);大量动态的生成反射类
HotSpot中方法区的演进
在jdk7及以前,习惯上把方法区,称为永久代(堆)。jdk8开始,使用元空间取代了永久代。
jdk8中:
-XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
jdk6中:
-XX:PermSize=10m -XX:MaxPermSize=10m
一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。所以换成元空间,元空间位于本地内存,仅存储类的元数据(Class Metadata),如类的结构信息、方法信息等。常量池和静态变量依然在堆中。垃圾回收器不再需要专门扫描永久代,简化了 GC 逻辑
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型
先来说说方法区内常量池之中主要存放的两大类常量:字面量和符号引用。字面量比较接近Java语言层次的常量概念,如文本字符串、被声明为fina1的常量值等。而符号引用则属于编译原理方面的概念,包括下面三类常量:
1、类和接口的全限定名
2、字段的名称和描述符
3、方法的名称和描述符
HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回
堆
- 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
- 堆,是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。
- 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除


其中:这个方法区,在jdk8后把方法区由之前永久代(堆)中移除了,移到元数据了
so接下来堆主要讲新生代和老年代
Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,大家可以通过选项"-Xmx"和"-Xms"来进行设置 。
·"-Xms"用于表示堆区的起始内存,等价于-XX:InitialHeapSize
"-Xmx"则用于表示堆区的最大内存,等价于-XX:MaxHeapSize.
一旦堆区中的内存大小超过"-Xmx"所指定的最大内存时,将会抛出OutOfMemoryError异常。
JDK 8 及更早 (Parallel/Serial GC)
新生代与老年代比例 :-XX:NewRatio=2 这个 1:2 的比例
Eden 与 Survivor 比例:-XX:SurvivorRatio=8 参数控制的 理论上的默认比例确实是 Eden:From Survivor:To Survivor = 8:1:1。但是,现代 JVM(尤其是 JDK 8 的 Parallel GC)默认开启了自适应大小策略(-XX:+UseAdaptiveSizePolicy)。当该策略开启时,JVM 会根据程序运行时的 GC 统计信息(如 GC 耗时、吞吐量、对象存活率等)动态调整 Eden 区和 Survivor 区的大小,
JDK 9 及以后 (默认 G1 GC)
新生代与老年代比例:从 JDK 9 开始,JVM 默认使用 G1 垃圾回收器。在 G1 收集器中,堆内存被划分为一个个大小相等的 Region(分区),不再使用固定的新生代和老年代比例
Eden 与 Survivor 比例:在 G1 垃圾回收器中,Eden 和 Survivor 也是由动态数量的 Region 组成的,不存在固定的 8:1:1 空间比例限制。
对象分配
关于垃圾回收:
频繁在新生区收集
很少在养老区收集
几乎不在永久区/元空间收集
过程:
1.new的对象先放伊甸园区。此区有大小限制。2.当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC/YGC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
3.然后将伊甸园中的剩余对象移动到幸存者0区。
4.如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区。
5.如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。
6.啥时候能去养老区呢?可以设置次数。默认是15次。
· 可以设置参数:-XX:MaxTenuriThreshold=设置对象晋升老年代的年龄阈值
7.在养老区,相对悠闲。当养老区内存不足时,再次触发GC:MajorGC,进行养老区的内存清理。
8.若养老区执行了Major GC之后发现依然无法进行对象的保存,就会产生00M异常java.lang.OutOfMemoryError: Java heap space
默认是15次?
对象晋升到老年代(也就是你说的"养老区")的默认次数确实是 15 次,而且这个数字的"天花板"之所以是 15,根本原因就在于对象头中用来存储年龄的空间只有 4 个 bit(比特位),换算成十进制:1111 换算成十进制就是 15
内存分配策略(或对象提升(promotion)规则):
如果对象在Eden出生并经过第一次MinorGC 后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。对象在Survivor 区中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15 岁,其实每个JVM、每个GC都有所不同)时,就会被晋升到老年代中。
针对不同年龄段的对象分配原则如下所示:
- 优先分配到Eden
- 大对象直接分配到老年代
尽量避免程序中出现过多的大对象 - 长期存活的对象分配到老年代
- 动态对象年龄判断
如果Survivor 区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold 中要求的年龄。 - 空间分配担保
-XX:HandlePromotionFailure
什么是空间分配担保?
在发生MinorGC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,
如果大于,则此次Minor GC是安全的
如果小于,则虚拟机会查看-XX:HandlePromotionFailure设置值是否允许担保失败
如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则尝试进行一次MinorGC,但这次Minor Gc依然是有风险的;如果小于或者HandlePromotionFailure=false,则改为进行一次Full GC。
说白了,如果老年代空间不够收留晋升的总对象且HandlePromotionFailure=true开启,会参考之前进入老年代平均值来决定进行MinorGC还是Full GC
什么是MinorGC,MajorGC ,Full GC?
针对HotSpot VM的实现,它里面的GC按照回收区域又分为两大种类型:
- 一种是部分收集(PartialGC)
- 一种是整堆收集(Ful1GC)
部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:
- 新生代收集(Minor GC/Young GC):只是新生代(Edeh\S0,S1)的垃圾收集
- 老年代收集(Major GC/0ldGC):只是老年代的垃圾收集。
目前,只有CMS GC会有单独收集老年代的行为。 .
注意,很多时候Major GC会和Ful1GC混淆使用,需要具体分辨是老年代回收还是整堆回收。 - 混合收集(MixedGC):收集整个新生代以及部分老年代的垃圾收集。目前,只有G1GC会有这种行为
整堆收集(Ful1GC):收集整个java堆和方法区的垃圾收集。
触发机制
年轻代GC(Minor GC)触发机制:
- 当年轻代空间不足时,就会触发Minor GC。这里的年轻代满指的是Eden区满,Survivor满不会引发Gc。(每次 Minor GC 会清理年轻代的内存。)
- 因为 Java对象大多都具备朝生夕灭的特性,所以Minor GC 非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。
老年代GC(Major GC/Ful1 GC)触发机制:
- Minor Gc会引发STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
- 指发生在老年代的GC,对象从老年代消失时,我们说"Major GC"或"FullGC"发生了。
出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在ParallelScavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。也就是在老年代空间不足时,会先尝试触发MinorGC。如果之后空间还不足,则触发Major GC - Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长。
- 如果Major GC后,内存还不足,就报OOM了。
Full GC触发机制:
触发Full GC 执行的情况有如下五种:
(1)调用System.gc()或者Runtime.getRuntime().gc()的调用,系统建议执行FullGC,但是不必然执行
(2)老年代空间不足
(3)方法区空间不足
(4)通过Minor GC后进入老年代的平均大小大于老年代的可用内存
(5) Eden区、 survivor spacee (From Space) 区向survivor spacel (To Space)区复制时,对象大小大于ToSpace可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
说明:ful1gc是开发或调优中尽量要避免的。这样暂时时间会短一些。