1. JVM内存结构
1.1 程序计数器
作用: 记住下一条JVM指令的执行地址(记录当前的代码执行到了第几行)
实现方法: 通过寄存器实现
特点:
- 线程私有
- 不会存在内存溢出,程序计数器是唯一不会存在OOM的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
1.2 虚拟机栈
栈帧: 每个方法运行时需要的内存。
虚拟机栈: 每个线程运行时所需要的内存.
一个栈内有多个栈帧组成,栈桢对应每次方法调用所占用的内存,每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法。
问题辨析:
- 垃圾回收是否涉及栈内存?
不需要涉及 - 栈内存设计越大越好吗?
并不是,有可能引起线程数目变少 - 方法内的局部变量是否线程安全?
·如果方法内的局部变量没有逃离方法的作用范围,即是线程安全的,反之2存在线程不安全的风险。
·如果局部变量引用了对象并套利的方法的作用范围,需要考虑线程安全问题。
栈内存溢出:
- 栈帧过多导致栈内存溢出
- 栈帧过大导致栈内存溢出
1.3 本地方法栈
和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
本地方法: 一个Native Method就是一个java调用非java代码的接口。一个Native Method是这样一个java方法:该方法的实现由非java语言实现。
本地方法栈:用于管理本地方法的调用
1.4 堆
Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
通过new关键字,创建对象都会使用堆内存。
特点:
- 线程共享,堆中对象需要考虑线程安全问题
- 有垃圾回收机制
堆内存溢出:大量对象占据了堆空间,这些对象都持有强引用导致无法回收,当对象大小之和大于Xmx参数指定的堆空间时就会发生堆溢出。
解决方法:
- 使用Xmx参数指定一个更大的堆空间。
- 由于堆空间不可能无限增长,分析找到大量占用对空间的对象,在应用程序上做出优化。
1.5 方法区
方法区:保存在着被加载过的每一个类的信息;这些信息由类加载器在加载类的时候,从类的源文件中抽取出来;static变量信息也保存在方法区中;
方法区的基本特点:
- 各个线程共享区域
- 在JVM启动时被创建,并且物理内存可以不连续
- 大小可以固定也可以是动态扩展的
- 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类会出现OOM错误。
- 会随着JVM的关闭而释放这一区域的内存
方法区内存溢出
- JDK1.8以前,永久代内存溢出
- JDK1.8以后,元空间内存溢出,垃圾回收效率高
方法区与永久代、元空间之间的关系
- 方法区是JVM规范中定义的一块内存区域,用来存储类的元数据、方法字节码、即时编译器需要的信息
- 永久代是Hotspot虚拟机对JVM规范的实现(1.8之前)
- 元空间是Hotspot虚拟机对JVM规范的实现(1.8之后),使用本地内存作为这些信息的存储空间
字符串常量池(Stringtable):
- 常量池就是一张表,虚拟机根据这张常量表找到要执行的类名,方法名,参数类型,字面量等信息。
- 运行时常量池,常量池是*.class文件中的,当该类被加载,他的常量信息就会放入运行时常量池,并把里边的符号地址变为真实地址。
字符串变量拼接
public class Demo1_22 {
//常量池中的信息都会被加载到运行时常量池中,这时a b abc都是常量池中的符号,还灭有变为java字符串对象
// ldc #2会把a符号变为“a“字符串对象,在串池中找”a”,如果没有,放入串池
// ldc #3会把a符号变为“b“字符串对象,在串池中找”b”,如果没有,放入串池
// ldc #4会把a符号变为“ab“字符串对象,在串池中找”ab”,如果没有,放入串池
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2; //new StringBuilder().append("a").append("b") 创建新的值为ab的对象,存到s4
String s5 = "a" + "b"; // javac在编译期的优化,结果已经在编译器确定为ab
System.out.println(s3 == s4); //false s3在串池中 s4相当于new出来的对象,保存在堆中
System.out.println(s3 == s5); //true javac在编译期的优化,结果已经在编译器确定为ab
}
}
字符串延迟加载
public class Demo1_22_StringNums {
public static void main(String[] args) {
int x = args.length; //x: 0 args: []
System.out.println(); //字符串个数3204
System.out.println("1");
System.out.println("2");
System.out.println("3");
System.out.println("4");
System.out.println("5"); //字符串个数3209
System.out.println("1");
System.out.println("2");
System.out.println("3");
System.out.println("4");
System.out.println("5"); //字符串个数3209
}
}
intern方法:
- 1.8: 将字符串中的对象尝试放入串池,如果有则不会放入,没有则放入串池,会把串池中的对象返回
String s = new String("a") + new String("b");
//先在常量池放入["a", "b"]
// 在堆中存入new String("a") new String("b")
// StringBuilder拼接了新的字符串 new String("ab")存在堆中
String s1 = s.intern();//将字符串中的对象尝试放入串池,如果有则不会放入,没有则放入串池,会把串池中的对象返回
System.out.println(s1 == "ab"); //true 因为调用intern()已经将"ab"放入串池。
System.out.println(s == "ab"); //true 因为调用intern()已经将"ab"放入串池。
//此时串池["a", "b", "ab"]
String s = new String("a") + new String("b");
//先在常量池放入["a", "b"]
// 在堆中存入new String("a") new String("b")
// StringBuilder拼接了新的字符串 new String("ab")存在堆中
String x = "ab";
String s1 = s.intern();//将字符串中的对象尝试放入串池,如果有则不会放入,没有则放入串池,会把串池中的对象返回
System.out.println(s1 == "ab"); //true
System.out.println(s == "ab"); //false 执行String x = "ab"时已经将ab放入串池,intern()便不会放入
//此时串池["a", "b", "ab"]
- 1.6: 将字符串中的对象尝试放入串池,如果有则不会放入,没有则将对象复制一份放入串池,会把串池中的对象返回
StringTable面试题
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b"; //ab放入常量池
String s4 = s1 + s2; //运行期间通过StringBu ilder进行拼接,存在堆中,相当于new String("ab")
String s5 = "ab";
String s6 = s4.intern();
System.out.println(s3 == s4); //false
System.out.println(s3 == s5); //true
System.out.println(s3 == s6); //true
String x2 = new String("c") + new String("d"); //new String("cd")
String x1 = "cd";
x2.intern();
System.out.println(s1 == s2); //false 如果x2.intern()先提一行 则为true
StringTable存储位置:
- 1.6: 常量池的一部分,随着常量池存储在永久代
- 1.7之后: 堆中
StringTable性能调优:
- 通过调整-XX:StringTableSize调整Hashtable桶的个数
- 考虑字符串对象是否入池
1.6直接内存(属于操作系统)
定义:
- 常见于NIO操作时,用于数据缓冲区。
- 分配回收成本较高,但读写性能高
- 不受JVM内存回收管理
1.7 Hotspot虚拟机创建对象全过程
1. 类加载检查
虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
2. 分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
内存分配的两种方式 (补充内容,需要掌握):
指针碰撞 :
适用场合 :堆内存规整(即没有内存碎片)的情况下。
原理 :用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
使用该分配方式的 GC 收集器:Serial, ParNew
空闲列表 :
适用场合 : 堆内存不规整的情况下。
原理 :虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。
使用该分配方式的 GC 收集器:CMS
选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的。
3. 初始化零值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值
4. 设置对象头
初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
5. 执行init方法
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始, 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
2. 垃圾回收
2.1 如何判断对象可以回收
1. 引用计数法
可能因为循环引用导致内存泄露
2. 可达性分析算法
- Java虚拟机中的垃圾回收机器采用可达性分析来探索所有存活的对象
- 扫描堆中的对象,看是否能够沿着GC Root对象为起点的引用链找到该对象,找不到,表示可以回收
哪些对象作为GC Root?
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 本地方法栈(Native 方法)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 所有被同步锁持有的对象
查看根对象方法: Eclipse Memory Analyzer
3. 四种引用
软引用、弱引用可以配合引用队列进行工作,被回收的时候进入引用队列,使用引用队列进行其他操作。
虚引用、终结器引用必须关联引用队列。
1. 强引用
我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。
只有所有GC Roots对象都不通过强引用引用该对象,该对象才能被垃圾回收。(没有直接引用才回收)
2. 软引用
没有被直接的强引用引用时,垃圾回收时有可能会被回收(内存不足时会被回收)。
3. 弱引用
没有被直接的强引用引用时,只要发生垃圾回收就会回收
4. 虚引用
是四种引用类型中最弱的一个。一个对象是否有虚引用的存在,完全不会对其生命周期构成影响,也无法通过虚引用获得一个对象实例。主要配合ByteBuffer引用
虚引用引用的对象被垃圾回收时,会放入引用队列,从而间接地使用一个线程调用Unsafe.freeMemory()方法释放直接内存。
5. 终结器引用(不推荐)
对象重写了finalize()终结方法,并且没有强引用引用时,可以被当成垃圾回收,使用终结器引用回收 。
被垃圾回收时,把终结器引用加入引用队列,finalizeHandeler()线程在引用队列进行回收。
finalizeHandeler()优先级低,迟迟不能回收
2.2 垃圾回收算法
1. 标记清除
分为两个阶段,先寻找堆中没有被GC Root直接或间接引用的对象进行标记,再进行清除
优点:速度快
缺点:有可能产生过多内存碎片,导致存储效率不高。
2. 标记整理——适用老年代
优点: 内存更紧凑,连续的空间更多,不会造成标记清除的碎片问题。
缺点: 速度慢
3. 复制——适用新生代
把内存区域划分成大小相等的两部分区域,如图,左边称为FROM右边称为TO,右边区域空闲
把未被引用的对象标记为垃圾
再把FROM区存活的对象复制到TO区,复制的过程中会进行碎片整理,此时FROM区全是垃圾,可以全部进行清除
清空后交换FROM和TO的位置
优点:不会产生碎片
缺点:需要双倍的内存空间
4.分代垃圾回收
堆内存分成两块:新生代、老年代
长时间使用的对象放在老年代,用完可以丢弃的放在新生代。
新创建的对象首先放在Eden区,当Eden区满了,触发一次垃圾回收,即Minor GC,把存活的对象存在幸存区To中,让幸存对象寿命+1。
紧接着交换From和To的位置。即为第一次垃圾回收产生的效果。
后续的垃圾回收,不仅要检测Eden区,还要检测幸存区中的对象是否存活,重复上述操作。
minor GC会出发stop the world即进行垃圾回收的时候,暂停其他用户线程,等垃圾回结束, 用户线程才恢复运行
当幸存区的对象超过15次(最大),将其晋升到老年代中
当新生代老年代都几乎全满,会触发一次Full GC,触发整个新生代和老年代的垃圾回收
Full GC采用标记清除或标记整理,回收效率回收速度都较低,STW的时间更长。
2.4 垃圾回收器
1. 串行
- 单线程
- 适合堆内存较小,适合个人电脑
新生代采用标记复制算法,老年代采用标记整理算法
垃圾收集器工作的时候必须stw
-XX:+UseSerialGC = Serial + SerialOld
2. 并行
- 多线程
- 关注吞吐量
- 适合堆内存较大的场景,需要多核CPU支持
- 让单位时间内,STW时间最短
新生代采用标记复制算法,老年代采用标记整理算法
是jdk1.8的默认收集器
-XX:+USeParallelGC ~ -XX:+USeParallelOldGC
-XX:+UseAdaptiveSizePolicy //采用自适应新生代的大小调整策略
-XX:GCTimeRatio=ratio //目标1: 根据设置的目标调整垃圾回收时间和总时间的占比
-XX:MAxGCPauseMillis //目标2: 最大暂停毫秒数
//上述两个目标是冲突的
-XX:ParallwlGCThreads=n //控制GC运行数量
3. 响应时间优先(例如CMS)
⼀种以获取最短回收停顿时间为⽬标的收集器
- 多线程
- 并发标记 并发清除
- 适合堆内存较大的场景,需要多核CPU支持
- 尽可能让STW的单次时间最短
采用标记清除算法实现
整个过程分为四个步骤
-XX:+UseConcMarkSweepGC ~ -XX:+UseParNewGC ~ SerialOld
4. G1——Garbage First
取代了CMS垃圾回收器
特点
- 兼顾响应时间和吞吐量
- 将堆内存划分为多个区域,每个区域都可以充当eden, 幸存区,老年代,humongous
使用场景:
- 同时注重吞吐量(Throughput)和低延迟,默认的暂停目标是200ms
- 超大堆内存,会将堆划分为多个大小相等的Region
- 整体上是标记+整理算法,两个区域之间是复制算法
相关JVM参数
-XX:+UseG1GC
-XX:+G1HeapRegionSize=size
-XX:MAxGCPauseMillis=time
5. ZGC
JDK13最新的垃圾回收器。stw的情况会更少
JDK 13 的最新垃圾回收器ZGC
4.1 G1垃圾回收阶段
CMS和G1,老年代内存不足时触发的垃圾回收,需要分两种情况,以G1为例:
- 当老年代内存和堆内存占比达到45%,会触发并发标记和混合收集
- 当垃圾回收速度跟不上垃圾产生速度,并发收集失败,触发Full Gc