主文章(我总结的面试题的索引目录—进不去就说明我还没写完) |
文章目录
- 1. JVM内存结构
- 2. 堆中对象,老年代
- 3. 垃圾回收器和算法
- 4. 调优
- 5. 线程
- 1. JDK1.8和以前版本因为C2编辑器bug造成多线程问题
1. JVM内存结构
常量池在哪?
- JDK1.7之前,常量池,字符串常量都存放在永久代,永久代是JDK1.7之前用来存放所有元数据,class,String,动态代理对象的必须指定大小的空间,容易溢出。
- JDK1.7,常量池从永久代取出,放在堆中。
- JDK1.8,JDK1.8移除了"永久代",并使用"元数据区"替代,字符串常量池依然放在堆中,而运行时常量池,静态常量池存放在"元数据区","元数据区"是堆之外的空间,没有大小限制(可以指定)
- 方法区,逻辑概念,1.7之前是永久代,1.8之后是元数据区。
简述JVM内存
- 当一个.class文件被类装载子系统加载后,类信息会保存到方法区中(静态变量、静态代码块、静态方法、Serializable的VersionID号等属于类的东西)
- 加载完成后,通过字节码执行引擎执行,取出数据,执行完再放回内存去
- 堆:任何被创建出来,比如new关键字new出来的对象,都保存到堆中
- 本地方法栈:保存本地方法,java中通过native关键字修饰的方法都是本地方法,也就是底层C++方法
- 线程栈:每个线程,都会分配一块线程空间(栈区域),用来保存你线程中的局部变量。比如main方法的执行,main本身就是一个线程。运行会产生一个线程空间,保存局部变量
- 程序计数器:每个线程都有一个,记录现在线程执行到哪一步了,它记录的是下一步该执行哪一行代码,因为线程总是会完全执行完成一条指令后才能被阻塞。记录当前线程正在执行的Java方法的JVM指令地址。指令地址在方法区中保存。由执行引擎记录
程序计数器的作用
- JVM执行引擎记录下一个应该执行的JVM指令的地方。它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
- 它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域.
- JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致
- 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址;或者,如果是在执行native方法,则是未指定值(undefned)
简述线程栈
- 线程栈开辟好后,会根据方法调用顺序,进行入栈,每个方法对应一个栈帧
- 比如main方法最先调用,那么它先入栈,进入栈低,然后执行代码
- 然后main方法调用了change方法,那么change方法入栈
- 如果change还调用了其它方法,或者递归调用自己本身,依然会生成新的栈帧入栈
- 如果change执行结束,那么它会出栈,继续执行他底下的栈帧,如上图所示,就是继续main的执行
简述栈帧
- 局部变量区:存放局部变量
- 假设a = 1;b=2;c=a+b;,那么先将a变量放到局部变量表,然后1放入操作数区(入栈),然后发现=赋值操作,取出1(出栈),放到局部变量表,a指向了1
- c = a+b;c变量放入局部变量表,然后将a的值1放入操作数区压栈,然后b的值2放入操作数区压栈,然后出栈2,出栈1,计算2+1=3,入栈3,然后是=赋值操作,局部变量表c指向3
- 操作数区:所有操作数,都是先进入这里,最终运算结束,才会放到局部变量区
- 动态链区:常用于线程切换。当某线程执行一半阻塞,会从程序计数器中记录这个线程执行到哪一个字节码指令的内存地址,这个内存地址在方法区中(类加载完成后,会将类信息放在方法区),当线程被重新唤醒后,可以继续执行。
- 方法出口:记录一个方法返回到哪里去的一个出口,比如main调用了change,那么change执行完成后,方法出口会记录他应该返回到main中哪个地方去。
什么时候会堆内存溢出,什么时候会栈溢出
- 堆,存放的是创建的对象,所以当创建的对象太多,垃圾回收不过来,堆满了时,再继续创建对象,会堆内存溢出
- 栈,存放线程空间的地方,当线程太多(并发量太大),栈装不下,会栈内存溢出
简述堆内存逻辑分区
- 分新生代和老年代两个区域,新生代有eden伊甸、survivor幸存者两个区域,survivor又分survivor1和sruvivor2两个区域
- 新生代中的对象,一般是存活时间较短,频繁被回收的,一般采用复制垃圾回收算法。而存活时间很长的对象,最终会进入老年代,也就是顽固对象,一般不会被回收,一般采用标记算法回收。
- 整个堆内存,老年代占2/3,新生代占1/3
- 在新生代区域,eden占8/10区域,survivor1占1/10,survivor占1/10区域
2. 堆中对象,老年代
对象如何分配到堆
- 对象出生时,尝试在栈上(stack)分配,如果栈装不下,进入伊甸区(Eden)
- 进入伊甸区后,触发垃圾回收之前,不需要被回收掉的对象则进入幸存区域(survivor),假设进入survivor1区。survivor区满了,分配担保,直接让其进入老年代,然后回收伊甸区和survivor2区
- 然后再垃圾回收一次时,注意,还有用的对象进入survivor2区,没用的留在survivor1区,和eden一起被回收,如果再回收一次那么再进入1区,然后再进入2区,一直循环直到年龄够了。也就是说,幸存者区,每次只有1个保存有用对象,另外一个和eden一起回收
- 年龄够了,进入老年区(Old)
- 垃圾回收的触发时机是满了的时候,比如eden满了的时候会触发一次垃圾回收,回收新生代,而老年代满了,Full GC会整个堆进行回收,无论新老年代
什么是MinorGC/YGC,什么是MajorGC/FullGC
- 两个都是触发回收的概念,比如老年代进行垃圾回收了,我们说执行了一次Full GC,它们不是垃圾回收器或垃圾回收算法,只是特定事件的名词称呼。
- MinorGC/YGC:年轻代空间耗尽时触发的回收
- MajorGC/FullGC:在老年代无法继续分配空间时触发,新生代老年代同时进行回收
对象何时进入老年代
- 超过XX:MaxTenuringThreshold参数指定的次数(YGC),如果不指定,那么根据不同算法,次数如下所示
- Parallel Scavenge :15次
- CMS : 6次
- G1 : 15次
- 动态年龄(当某些情况下,并不是达到上面规定的次数才进入老年代)
比如s1(幸存者1区)中拷贝一些东西到s2,这些东西拷贝过来后,总体的年龄已经超过幸存者区域默认规定的年龄总和的50%了,那么此时,不考虑次数,直接将当前年龄最大的放入old区(
就是如果幸存者区的对象年龄加起来,超过总年龄(参数设置的年龄总量)的50%,年龄大的直接晋升老年代
)
- 前对象是否很大,如果很大,直接进入老年区,最后通过FGC(老年代空间不够了,进行的回收)回收
- 总结
- 当一个对象new后,进行栈上分配,用完可以直接pop弹出,直接结束对象生命周期,这就是栈上分配的好处
- 栈满了,没办法继续栈上分配,那么需要判断当前对象是否很大,如果很大,直接进入老年区,最后通过FGC(Full GC老年代空间不够了,进行的回收)回收
- 如果对象不是很大,那么进行TLAB(线程本地分配,好处就是不需要线程挣用与同步),如果大小适当,直接进入伊甸区中为其分配的1%(默认情况下是1%),否则,直接放在伊甸区
- 此时,伊甸区的对象如果进行1次垃圾回收,
没有被清除,进入幸存1区(注意,清除过程是:标记是否需要存活,需要进入幸存1或2区,年龄+1,然后清除eden和另外一个幸存区的对象)
(S1),再次回收,判断年龄,如果年龄够大,进入老年区,不够大进入S2,S2中再回收一次,就直接再进到S1,不判断年龄,S1再回收,才继续判断年龄
3. 垃圾回收器和算法
什么是STW(stop the world),什么时候触发
- 让所有工作中线程停止,正在工作中,完成手里头工作再停
- 垃圾回收器,回收垃圾时触发
- STW时间不是特别长,仅仅在Full GC(触发老年代回收)时,因为是老年代,新生代一起回收,所以STW要更长一些,会让你感觉到明显的卡顿一下,当你使用系统,突然卡顿一下,就说明Full GC了
- 我们要尽量减少Full GC的次数,合理的代码逻辑编写(分配内存),JVM合理的调优,可以解决FullGC频繁触发的问题,基本上几天一次,或者一周才会触发一次。
简述你知道的垃圾回收器
历史:JDK诞生时,Serial就开始追随,为了提高效率,诞生PS(Parallel Scanvenge),为了配合CMS(CMS在1.4版本后期引入,开启了并发回收时代,但毛病较多,暂时没发现有JDK版本使用),诞生了PN(ParNew),
Serial表示单线程
,Parallel表示多线程并行
,CMS表示多线程并发(工作和垃圾回收同时进行)
- Serial 年轻代 串行回收,STW(让所有工作中线程停止,正在工作中,完成手里头工作再停),然后单线程进行回收,回收完,让线程继续工作,也就是只有一个清理线程清理
- Parallel Scavenge(PS) 年轻代 并行回收,和Serial不同点在于,它清理线程有多个,STW后,多个线程进行回收
- ParNew(Parallel New,PN) 年轻代 配合CMS的并行回收,同样要先STW,它在PS基础上进行了增强,以便和CMS配合,
PN响应时间优先(配合CMS),PS吞吐量优先
- SerialOld(SO) :Serial 是回收年轻代,SerialOld 回收老年代
- ParallelOld(PO):PS回收年轻代,PO回收老年代
- ConcurrentMarkSweep(CMS) 老年代 并发的,垃圾回收和应用程序同时运行,降低STW的时间(200ms)。另外,它使用的算法是三色标记+Incremental Update
- CMS问题较多,所以现在没有一个版本是默认CMS,只能手工指定,
- CMS既然是MarkSweep,就一定会有
碎片化问题
,碎片到达一定程度,CMS的老年代分配对象分配不下的时候,使用SerialOld进行老年代回收
- 想象:PS + PO -> 回收10G内存垃圾(回收一次用来10十多秒) ,换CMS垃圾回收器 -> PN + CMS +SerialOld(几个小时-几天的STW)
- 几十G内存,单线程回收->G1+FGC 几十个G -> 上T内存的服务器ZGC
- CMS工作过程
- 初始标记,先STW,然后将垃圾的内些根标记上,具体参考上面找垃圾内一节介绍的根可达算法,我们就是标记内些根,此步骤耗费时间不多,和Serial 等垃圾回收STW比非常短
- 并发标记,占据整个过程80%的时间,和工作线程并发执行,标记所有垃圾
- 重新标记,并发标记过程中产生的新垃圾,或者已经标记为垃圾的,现在却不再是垃圾的,先STW,然后重新标记一下,耗费时间也很短,因为不多
- 并发清理,和工作线程同时进行,但是此时如果产生新的垃圾,不会清理这些新产生的,这些新垃圾叫浮动垃圾,只能等下次CMS执行一起清理
- G1(10ms),只在逻辑上分年轻代,老年代,使用三色标记+SATB算法
- ZGC(1ms) ,PK C++,使用算法,颜色指针ColoredPointers + 读屏障
- Shenandoah,使用算法 颜色指针+ 读屏障
- Eplison
- 1.8默认垃圾回收:PS + ParallelOld
- 常见回收器组合:Serial组合(Serial+SerialOld),Parallel组合(Parallel Scavenge+ParallelOld),ParNew+CMS组合
- 垃圾回收器根内存的大小关系
- Serial 几十M
- PS 上百M-几个G
- CMS 20G
- G1 几百G
- ZGC 4T
JVM如何找到垃圾,什么算法?
如何找到垃圾?重点掌握根可达算法
- reference count 引用计数算法:一个对象有多少引用指向,就记录一个数值,如果为0,表示没有引用指向,就是垃圾。下图中红圈表示对象,蓝线表示引用,蓝色方块表示值,数字3代表现在有3个对象引用它,
当第一个红圈对象使用完,断开引用,此时数字3变为2
,当数字变为0
表示没有对象引用
,此时这个蓝色方块表示垃圾
,需要回收- 但是这种算法无法处理以下结构(循环引用),每一个蓝方块,都记录自己下一个是谁,那么每个计数都是1,当某个蓝方块被断开引用,此时它就变为垃圾,但是它还记录着下一个蓝方块,如果它被回收,那么它引用的蓝方块就找不到了
- 根可达算法:从根(线程栈局部变量、静态变量、常量池中的引用)向下遍历,
可以到达的对象不是垃圾,进行标记(Mark)
,而到不了的对象,不会被标记,就是垃圾
。下图中,最上面的4个紫色方块表示根,如果它的引用通过路径可以找到,那么找到的都不是垃圾,通过路径从根无法到达的,便是垃圾,比如右下角的3个方块,无法从根找到,则被回收
JVM如何清除垃圾,什么算法?
- Mark-Sweep 标记清除
- 首先所有未使用空间是保存到特定的空闲列表中的,此算法不会找列表中的东西。然后此算法会找到所有存活对象(不可回收对象),将剩下的对象标记为可回收对象,然后清除被标记对象。适合在存活对象多的情况下使用。(共扫描两遍,第一遍找所有存活对象,第二遍将剩下的找到回收)
- Copying 拷贝
- 同样空闲列表不做考虑,找到所有存活对象拷贝一份,然后清除所有对象(无论是有用的不可回收还是没用的可回收),之后通过一系列对象引用转换操作,让之前的引用对象重新指向拷贝后的对象。(
就是把能用的先复制一份,然后把除了刚复制的对象以外的都删了,难点在于原来的引用如何指向复制后的对象
,适用于存活对象少,扫描一次,效率高,无碎片,但移动过程中浪费空间)
- Mark-Compact 标记压缩
- 空闲列表不考虑,先找到所有不可回收对象,放在最前面,然后回收垃圾(扫描两次,第一次找不可回收,第二次将不可回收移动到前面。涉及对象的移动,效率有一定影响,但方便对象分配,不会产生内存减半问题,不会产生碎片)
- 分代收集算法
- 就是新生代和老年代根据对象不同的特点,采用上面不同的算法进行回收,取名为分代收集
4. 调优
调优的目的是什么?
减少Full GC的次数和执行时间
什么原因会导致Full GC频繁和执行时间过长?
- 如果创建对象过多,会频繁YGC(年轻代回收),对象年龄会涨的很快。就会很早进入老年代
- 大对象过多,也会导致老年代空间快速被占满
- 一旦老年代满,就会Full GC
- 而当我们内存分配的空间较大时,Full GC的执行时间就会过长,因为空间很大,对象很多
如何在代码层面,减少Full GC的次数?
- 减少创建对象的数量,例如使用单例设计模式
- 减少使用全局变量和大对象
有JVM层面的调优思路吗?
- 使用栈上分配,栈上分配比堆上快的多,用完可以直接pop弹出,直接结束对象生命周期
- 调整新生代的大小到最合适(改分配的内存大小),一定程度上,拉长对象增长年龄的时间,使其尽可能在年轻代被回收
- 设置老年代的大小为最合适(和上面同理)
- 选择合适的GC收集器,前面垃圾回收器那介绍的很清楚了,给出多种GC回收器的组合,需要根据经验,进行选择。这个只能自己积累了。下面提供换成G1的思路,指定参数-XX:UseG1GC : G1即可使用。
- JDK8默认的PS + ParallelOld垃圾回收器,ParallelOld的垃圾回收算法,就是满了以后GC,那么此时可以将它们换成G1
- JDK9默认就是G1收集器,G1和其它收集器不同,它的新老年代比例无需手工指定,动态调整区域大小,比如一个区域有点大,让stw时间过长,那么G1会动态的将区域调小一点
- 它不在把内存分为固定的几块了(eden,survivor…)而是若干个小块,每个小块可以是任意区域,而且大对象,单独从Old中抽离
- 回收时,它不是整个回收,而是回收一部分,看你现在够不够用,不够用继续回收,够用了,就不回收了。
5. 线程
1. JDK1.8和以前版本因为C2编辑器bug造成多线程问题
1. 下面程序为什么执行中断(JDK版本1.8及以前版本)
- 先看效果
- 看看为什么中断?
- 这里改了一点,为什么又可以运行了?
public class Test{
static long counter;
public static void main(String[] args) throws Exception{
System.out.println("main start");
startBusinessThread();//启动业务线程
startProblemThread();//启动问题线程
//等待线程启动执行
Thread.sleep(500);
// 执行GC
System.gc();
System.out.println("main end");
}
/**
* 启动问题线程
*/
public static void startProblemThread(){
new Thread(new MyRun()).start();
}
/**
* 问题线程
*/
public static class MyRun implements Runnable{
@Override
public void run() {
System.out.println("Problem start");
for(int i = 0;i<100000000;i++){
for (int j =0;j<1000;j++){
counter+=i%33;
counter+=i%333;
}
method();
}
System.out.println("Problem end");
}
}
public static void method(){
// Thread.currentThread().getId();
}
/**
* 业务线程,每一秒输出,执行业务1,执行业务2
*/
public static void startBusinessThread(){
new Thread(()->{
System.out.println("业务线程-1 start");
for(;;){
System.out.println("执行业务1");
try{
Thread.sleep(1000);
}catch (InterruptedException e){
e.printStackTrace();
}
}
}).start();
new Thread(()->{
System.out.println("业务线程-2 start");
for(;;){
System.out.println("执行业务2");
try{
Thread.sleep(1000);
}catch (InterruptedException e){
e.printStackTrace();
}
}
}).start();
}
}
- 这个问题属于JDK1.8及以前(包含1.8)C2编辑器的bug
- System.gc();要求JVM执行GC,理论上,
执行GC先要将业务线程停掉
,JVM停掉线程的点是"线程安全点safe point"
- 当主线程到达safe point,业务线程达到safe point后,此时问题线程并没有到达safe point,他们会等它。
C2认为 问题线程是有限循环
(问题线程里面就是两个for循环,很大,还一直取模,所以很慢)- 所以
当我们将int换成long后
,C2不再认为它是有限循环了
,因为long类型太大了,是个大数循环,等它要花很长时间,C2就会检测问题线程的safe point
另外线程安全点的检测,会发生在方法调用前
。我们一开始注释掉method()方法中的代码后,为什么没有发生检测呢?因为它是空方法,编译器很聪明,空方法不执行
。所以虽然我们在问题线程中调用了method()但只要方法是空方法
,它不执行,依然不会找safe point
- 这也是为什么我们在method()方法中加上一句代码,程序又正常执行了
- 上面的搞懂了,我们接着往下看(method()中也写一个int循环),可以正常运行
- 不写循环,写点int数,为什么又不行了?
- 看来
编译器
没那么聪明,对于稍微复杂点的代码没有方法内联优化
,将方法中的for循环代码也判断成有限循环- 为什么简单写了个int f = 0;f++;又行了呢?看来只有像这样
简单的代码,编译器,才会进行内联优化
- 接下来,介绍一种新的解决办法,直接关掉C2编辑器的栈上替换(查看JVM各个参数值方式:1. HotSpot vm中的各个globals.hpp文件 查看jvm初始的默认值及参数)
-XX:-UseOnStackReplacement//关闭栈上替换
- 什么是关闭栈上替换呢?
- 我们方法是在栈上执行的
- 首先JVM默认是打开此功能的(栈上替换), C2编译器在执行时,会默认的把它优化掉,把栈上这一块字节码给替换掉了,执行编译好的代码
- 我们加上参数,让它不替换,也就是这段代码不优化,就解决问题了
- 除了C2编辑器,还有C1,C2执行效率比C1高30%以上,Hotspot默认开启分层编译,若只想开C2,可使用启动参数 -XX:TieredCompilation关闭分层编译,若只想用C1,可以使用启动参数-XX:TieredStopAtLevel=1打开分层编译,同时指定使用C1编辑器的1层编译。
- 那么我们解决上面问题,又一种方式就是,-XX:TieredStopAtLevel=3,去掉第4个
-XX:TieredStopAtLevel=3
- 当然还有一种解决办法,用volatile关键字
- 当然你可能会说System.gc()线上不让用,但是你写个list,一种往里面添加对象,把JVM堆栈空间调小,然后他就会触发GC,效果一样
- 怎么定位问题呢?有没有日志打印手段呢?
-XX:+SafepointTimeout //添加线程安全点超时
-XX:SafepointTimeoutDelay=1000 //超时时间1000ms