努力经营当下,直至未来明朗!
普通小孩也要热爱生活!
这个章节中的内容都是“八股文”。(“八股文”:面试要考,但是工作中用不到)
JVM是一个大话题,市面上广泛流传的面试题主要是这三个方面:
① JVM内存划分
② JVM类加载
③ JVM的垃圾回收
Java程序:是一个名为java的进程,这个进程就是所说的“JVM”。(JVM也就是运行起来的java进程)
JVM运行的时候会先从操作系统这里申请一大块空间,在这个基础上再把这个内存空间划分为几个小的区域。
区域划分:(java1.7之前)
① 堆:放的new的对象
② 方法区:放的是类对象(加载好的类)
③ 栈:放的是方法之间的调用关系
④ 程序计数器:放的是下一个要执行的指令的地址
栈又可以进行细分:
① 虚拟机栈:java里面用来保存调用关系的内存空间
② 本地方法栈:本地方法,也就是java内部C++写的代码,调用关系的内存空间
【实例】
① 代码中的局部变量:栈
② 代码中的成员变量:堆
③ 代码中的静态变量:方法区
一个JVM进程中:堆和方法区只有一份,栈和程序计数器每个线程都有自己的一份。

区域划分(java1.8之后):
① 堆:放的new的对象
② 栈:放的是方法之间的调用关系
③ 程序计数器:放的是下一个要执行的指令的地址
④ 元数据区:用的是本地内存(JVM内部C++代码中搞的内存)
区别于1.7之前的:去掉了方法区,多了元数据区。
方法区是在JVM申请到的这一大块内存中划分的区域里,而元数据区是用的本地内存】
面试的时候,如果问到JVM内存划分,就回答java1.7之前的划分就行!
一般不会直接考概念,大概率是结合代码来考的:(一般是笔试考的)
1)实例1:

① a:a是成员变量,在堆上
② b:b是静态变量,是在类对象里,就是在方法区上
③ t: t是局部变量,在栈上
【很大的误区】
t 是引用类型的变量,那它不是应该在堆上吗??
变量在哪个部分和变量的类型无关!只和变量的形态有关(局部、成员、静态)!!
2)实例2:

此时aa在内存的哪个部分:
① t2是静态变量,是在方法区上;
② 而new Test2(),new后面的Test2依旧是在堆里的,所以aa就是在堆里的。
【补充】类对象:
① 在反射以及jackson、synchronized中都出现过
② 类对象其实就描述了这个类是啥样的,如有哪些属性(属性名字、类型、private/public),有哪些方法(方法名字、参数个数、类型、返回值类型、private/public),以及继承自哪个父类,实现哪些接口等。
③ 类对象也是创建实例的具体依据。
1)加载:
找到.class文件,读取文件的内容,并且按照.class规范的格式来解析
2)验证:
检查当前的.class里的内容格式是否符合要求
【.class文件长啥样,官方文档上有明确描述】

3)准备:给类里的静态变量分配内存空间。
如:static int a = 123; 准备阶段就是给a分配内存空间(int类型是4个字节),同时这些空间的初始情况全是0!
4)解析:初始化字符串常量,把符号引用(类似于占位符)替换成直接引用(类似于内存地址)。
① .class文件中会包含字符串常量(代码里很多地方也会使用到字符串常量)。
② 比如代码里有一行 String s = “hello”;
在类加载之前,“hello”这个字符串常量是没有分配内存空间的(得类加载完成之后才有内存空间);没有内存空间,s里也就无法保存字符串常量的真实地址,只能先使用一个占位符标记一下(这块是“hello”这个常量的地址),等真正给“hello”分配内存之后就可以使用这个真正的地址替代之前的占位符了。
5)初始化:针对类进行初始化,初始化静态成员,执行静态代码块,并且加载父类…
【上述环节可能会以面试题的形式出现】
谈谈 类加载 大概有哪几个环节,都是干啥的
使用一个类:
① 创建这个类的实例(new)
② 使用了这个类的静态方法/静态属性
③ 使用类的子类(加载子类会触发加载父类)
类加载的重要环节:
解析.class,校验.class,构造.class对象…
【这是类加载的初心】
1)双亲委派模型 只是决定了按照啥样的规则来在哪些目录里去找.class文件
2)类加载器:
各自负责一个各自的片区(负责各自的一组目录),但是互相配合。

按照这个顺序加载,最大的好处就在于如果程序员写的类,正好全限定类名和标准库中的类冲突了【比如自己写的类也叫做java.lang.Thread]】此时仍然可以保证类加载到标准库的类,防止代码加载错了带来问题。
手动释放最大的问题就在于容易忘记 => 造成内存泄露
解决内存泄露问题的一个主流方案就是GC(垃圾回收),如Java、PHP、JS、Go、Python等都在使用这种机制。
GC:程序员只需要申请内存,释放内存的工作就交给JVM来完成。JVM会自动判定当前的内存是啥时候需要释放的,认为内存不再使用了就会进行自动释放。
那么:为啥C++不引入GC?
① C++的设计理念是保证性能的极致。
② 使用GC最大的问题就在于引入额外的开销:时间(程序跑的慢)[GC中最大的问题就是STW问题[Stop The World],反应在用户这里就是明显卡顿] + 空间[消耗额外的CPU/内存资源]
(补:衡量GC好坏的重要指标之一就是STW问题)
③ 使用智能指针能够在一定程度上缓解内存泄露问题
(GC可以基本把内存泄露问题处理得差不多[但也不是100%])
JVM主要内存分为以下几个部分:
① 堆:GC主要就是针对堆来回收的 (堆上放的是一个一个new出来的对象!)
② 方法区:类对象,加载之后也不太会卸载,所以也不必回收
③ 栈:释放时机确定,不必回收
④ 程序计数器:固定的内存空间,不必回收
一定要保证:彻底不使用的内存才能回收!宁可放过,不能错杀!
GC中回收内存不是以“字节”为单位,而是以“对象”为单位。

这里怎么找和怎么回收还有一系列比较复杂的策略
比如引用是一个局部变量,出了作用域则引用就0; or 引用是个成员变量,所在的对象被销毁了,此时引用也0
③ 当引用计数器数值为0的时候,就说明当前这个对象已经无人能够使用了,此时就可以进行释放了。
④ 引用计数器的优点:简单,容易实现,执行效率也比较高。
⑤ 引用计数器的缺点:
-空间利用率比较低,尤其是小对象。比如计数器是个int,而你的对象本身里面只有一个int成员。
-可能会出现循环引用的情况。

2)可达性分析:[是JVM采取的方法]
约定一些特定的变量,称为“GC roots”。每隔一段时间就从GC roots 出发进行遍历,看看当前哪些变量是能够被访问到的,能被访问到的变量就称为“可达”,否则就是“不可达”。
GC roots:
① 栈上的all变量
② 常量池引用的对象
③ 方法区中引用类型的静态变量
每一组都有一些变量,每个变量都视为起点,从这些起点出发尽可能进行遍历,就能够找到所有访问到的对象。
[类似于二叉树通过root.进行访问,可以访问就是可达]
【注意】
① 谈谈垃圾回收(不限定于java)中是如何判定某个对象是垃圾的(both:引用计数+可达性分析)
② 谈谈Java的垃圾回收中是如何判定某个对象是垃圾的(only可达性分析)
回收垃圾的策略:
① 标记清除
② 复制算法
③ 标记整理
④ 分代回收

(复制算法其实就类似于“土豪”,双份准备,但是只使用其中一份)


有一组线程,周期性的扫描代码里所有的对象。如果一个对象经历了一次GC还没有被回收,此时就认为年龄+1
一个基本的经验规律:
如果一个对象寿命比较长,那它大概率会活得更久(要挂早挂了)
基于经验规律,就针对对象的年龄进行了分类,把堆里的对象分成了:新生代(年龄小的对象,但是大概率会挂)和老年代(年龄大的对象,大概率寿命会更久一些)。
对于新生代,GC的扫描频率更高,因为挂的概率更大;而对于老年代,GC的扫描频率更低,因为挂的概率更小一些。
新生代中又分为:伊甸区和生存区、生存区。
刚创建出来的新对象进入伊甸区。
如果新对象熬过一轮GC没挂,就通过复制算法复制到生存区。
(每经过一轮GC,伊甸区的内容就会全部被清空:没挂的新对象被复制到生存区)
生存区的对象也要经历GC的考验,每次熬过一轮GC,就会通过复制算法拷贝到另一个生存区中。
① 同一时刻,只有一个生存区在使用。
② 通过考验的被复制到另一个生存区,未通过和通过的最后都会在该生存区中被直接释放。
只要这个对象不消亡,就会在这两个生存区之间来回拷贝。
每一轮拷贝,每一轮GC都会筛选掉一大波对象。
如果一个对象在生存区中反复坚持了很多轮还没有被释放,此时就会进入老年代了。(轮数在不同版本的JVM中可能是不一样的,网上常见是15,但是不建议记住!面试也不要具体回答!)
如果对象进入了老年代,其实并不意味着进入了保险箱,也是会定期进行GC的,只是频率更低了一些。
这里采取标记整理的方法来处理老年代的对象。
上述规则有一个特殊的情况:
如果对象是一个非常大的对象,则直接进入老年代。
理由:
① 大对象进行复制算法的开销太大
② 很大的对象是花费了很多资源创建出来的,所以一般情况下是不会立即进行销毁的。

【补充】
面试时说数字的情况:
要么就是通过自己合理实验得出的,或者是查阅权威文献(如JDK官方文档等)得出的严谨的结论,否则不要轻易说数字!
① 初始标记(CMS initial mark): 初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要“Stop The World”。
② 并发标记(CMS concurrent mark:) 并发标记阶段就是进行GC Roots Tracing的过程。
③ 重新标记(CMS remark): 重新标记阶段是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的 时间短,仍然需要“Stop The World”。
④ 并发清除(CMS concurrent sweep): 并发清除阶段会清除对象。
由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以:从
总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
3. 优点:
CMS是一款优秀的收集器,它的主要优点在名字上已经体现出来了:并发收集、低停顿。
4. 缺点:
① CMS收集器对CPU资源非常敏感。
其实,面向并发设计的程序都对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程
停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。
CMS默认启动的回收线程数是(CPU数量+3)/ 4,也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。但是当CPU不足4个(譬如2个)时,CMS对用户程序的影响就可能变得很大。② CMS收集器无法处理浮动垃圾。
CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。要是CMS运行期间预留的内存无法满足程序需要,就会出现一 次“Concurrent Mode Failure”失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。
③ CMS收集器会产生大量空间碎片。
CMS是一款基于“标记—清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。
参考:ZGC垃圾收集器
