努力经营当下,直至未来明朗!
文章目录
- 【JVM内存划分】
- 【JVM类加载】
- 【JVM垃圾回收机制GC】
- 一)GC是啥
- 二)GC回收哪部分内存
- 三)具体怎么回收
- 四)怎么找垃圾(判定某个对象是否是垃圾)
- 五)具体怎么回收垃圾
- 1. 标记清除
- 2. 复制算法
- 3. 标记整理
- 4. 分代回收
- 【简单回顾小结】
- 【补】垃圾回收器(了解,没时间跳过)
- CMS收集器(老年代收集器,并发GC)
- G1收集器(唯一一款全区域的垃圾回收器)
- ZGC收集器
- 小结
普通小孩也要热爱生活!
-
这个章节中的内容都是“八股文”。(“八股文”:面试要考,但是工作中用不到)
-
JVM是一个大话题,市面上广泛流传的面试题主要是这三个方面:
① 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就是在堆里的。
【JVM类加载】
- 类加载是干啥的?
① Java程序在运行之前需要先进行编译,也就是.java 文件编译为 .class文件(.class文件是二进制字节码文件);
② 运行的时候,java进程(JVM)就会读取对应的.class文件并且解析内容,在内存中构造出类对象并进行初始化。
③ 类加载其实就是把类从文件加载到内存中。
【补充】类对象:
① 在反射以及jackson、synchronized中都出现过
② 类对象其实就描述了这个类是啥样的,如有哪些属性(属性名字、类型、private/public),有哪些方法(方法名字、参数个数、类型、返回值类型、private/public),以及继承自哪个父类,实现哪些接口等。
③ 类对象也是创建实例的具体依据。
- 类加载的大体过程
① 加载
② 连接(验证、准备、解析)
③ 初始化
【这些术语都是出自java官方文档(Java语言规范&JVM规范):Java官方文档】
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)
② 使用了这个类的静态方法/静态属性
③ 使用类的子类(加载子类会触发加载父类)
- 重点考察部分:双亲委派模型(JVM部分最高频的问题之一)
(其实“双亲委派模型”是类加载中是不太重要的环节)
类加载的重要环节:
解析.class,校验.class,构造.class对象…
【这是类加载的初心】
1)双亲委派模型 只是决定了按照啥样的规则来在哪些目录里去找.class文件
2)类加载器:
- JVM加载类是由类加载器(class loader)这样的模块来负责的。
- JVM自带了多个类加载器(程序员自己也可以实现类加载器,但是我们暂时不需要考虑)
- JVM自带的类加载器主要有三个:
① Bootstrap ClassLoader:负责加载标准库中的类
② Extension ClassLoader:负责加载JVM扩展的库的类(语言规范里没有写,但是JVM给实现出来了)
③ Application ClassLoader:负责加载咱们自己的项目里的自定义的类
各自负责一个各自的片区(负责各自的一组目录),但是互相配合。
- 描述上述类加载器相互配合的工作过程就是双亲委派模型。
① 以上三种类加载器是存在父子关系的:
② 进行类加载的时候,输入的内容是 全限定类名,形如java.lang.Thread(也就是带有包名的类)
③ 加载的时候是从Application ClassLoader开始的
④ 某个类加载器开始加载的时候并不会立即扫描自己负责的路径,而是先把任务委派给 父 “类加载器” 来进行处理
⑤ 找到最上面的Bootstrap ClassLoader再往上就没有 父 类加载器了,此时就只能自己动手加载了,也就是开始扫描自己负责的路径
⑥ 如果父亲没有找到类,就交给自己的儿子继续加载
⑦ 如果一直找到最下面的Application ClassLoader也没有找到类,就会抛出一个“类没找到”异常,类加载就失败了。
按照这个顺序加载,最大的好处就在于如果程序员写的类,正好全限定类名和标准库中的类冲突了【比如自己写的类也叫做java.lang.Thread]】此时仍然可以保证类加载到标准库的类,防止代码加载错了带来问题。
【JVM垃圾回收机制GC】
一)GC是啥
- 学C的时候,创建内存的方式有两种:
① 直接定义变量,变量就对应内存空间(内存释放时机是确定的,出了作用域就释放)
② malloc申请内存(动态内存申请)=> 务必需要通过free来进行释放。如果不手动free,这个内存会一直持续到程序结束。
手动释放最大的问题就在于容易忘记 => 造成内存泄露
-
解决内存泄露问题的一个主流方案就是GC(垃圾回收),如Java、PHP、JS、Go、Python等都在使用这种机制。
-
GC:程序员只需要申请内存,释放内存的工作就交给JVM来完成。JVM会自动判定当前的内存是啥时候需要释放的,认为内存不再使用了就会进行自动释放。
-
那么:为啥C++不引入GC?
① C++的设计理念是保证性能的极致。
② 使用GC最大的问题就在于引入额外的开销:时间(程序跑的慢)[GC中最大的问题就是STW问题[Stop The World],反应在用户这里就是明显卡顿] + 空间[消耗额外的CPU/内存资源]
(补:衡量GC好坏的重要指标之一就是STW问题)
③ 使用智能指针能够在一定程度上缓解内存泄露问题
(GC可以基本把内存泄露问题处理得差不多[但也不是100%])
- 虽然GC会存在额外的开销,但是依旧是我们必不可少的好伙伴。
理由:开发效率 > 运行效率
二)GC回收哪部分内存
-
JVM主要内存分为以下几个部分:
① 堆:GC主要就是针对堆来回收的 (堆上放的是一个一个new出来的对象!)
② 方法区:类对象,加载之后也不太会卸载,所以也不必回收
③ 栈:释放时机确定,不必回收
④ 程序计数器:固定的内存空间,不必回收 -
一定要保证:彻底不使用的内存才能回收!宁可放过,不能错杀!
GC中回收内存不是以“字节”为单位,而是以“对象”为单位。
三)具体怎么回收
- 先找出垃圾(看看谁是垃圾)
- 再回收垃圾(释放内存)
这里怎么找和怎么回收还有一系列比较复杂的策略
四)怎么找垃圾(判定某个对象是否是垃圾)
- 如果一个对象再也不用了,就说明是垃圾了
- 在Java中,对象的使用需要 凭借引用。假如有一个对象,已经没有任何引用能够指向它了,这个对象自然就无法使用了。
- 判断某个对象是否为垃圾最关键的要点:通过引用来判定当前对象是否还能被使用,没有引用指向就视为是无法被使用了。
- 两种典型的判定对象是否存在引用的方法:
1)引用计数:[不是JVM采取的方法] (Python、PHP用这个)
① 给每个对象都加上一个计数器,这个计数器就表示“当前的对象有几个引用”。
② 每次多一个引用指向该对象,该对象的计数器就+1;
每次少一个引用指向该对象,该对象的计数器就-1。
比如引用是一个局部变量,出了作用域则引用就0; or 引用是个成员变量,所在的对象被销毁了,此时引用也0
③ 当引用计数器数值为0的时候,就说明当前这个对象已经无人能够使用了,此时就可以进行释放了。
④ 引用计数器的优点:简单,容易实现,执行效率也比较高。
⑤ 引用计数器的缺点:
-空间利用率比较低,尤其是小对象。比如计数器是个int,而你的对象本身里面只有一个int成员。
-可能会出现循环引用的情况。
2)可达性分析:[是JVM采取的方法]
约定一些特定的变量,称为“GC roots”。每隔一段时间就从GC roots 出发进行遍历,看看当前哪些变量是能够被访问到的,能被访问到的变量就称为“可达”,否则就是“不可达”。
GC roots:
① 栈上的all变量
② 常量池引用的对象
③ 方法区中引用类型的静态变量
每一组都有一些变量,每个变量都视为起点,从这些起点出发尽可能进行遍历,就能够找到所有访问到的对象。
[类似于二叉树通过root.进行访问,可以访问就是可达]
【注意】
① 谈谈垃圾回收(不限定于java)中是如何判定某个对象是垃圾的(both:引用计数+可达性分析)
② 谈谈Java的垃圾回收中是如何判定某个对象是垃圾的(only可达性分析)
五)具体怎么回收垃圾
回收垃圾的策略:
① 标记清除
② 复制算法
③ 标记整理
④ 分代回收
1. 标记清除
- 标记出垃圾之后,直接把对象对应的内存空间进行释放。
- 这种方式最大的问题:内存碎片!
也就是说:会导致整个内存“支离破碎”,如果想要申请一块连续的内存空间就申请不了,导致内存空间的浪费。
(申请的时候都是连续的内存空间)
2. 复制算法
- 复制算法是针对上述的“内存碎片”问题来引入的办法。
(复制算法其实就类似于“土豪”,双份准备,但是只使用其中一份)
- 复制算法不是原地释放,而是将“非垃圾”(可以继续使用的对象)拷贝到另一侧;然后将该侧的空间完全释放。
- 复制算法的缺点:
① 空间利用率更低了(用一半丢一半)
② 如果一轮GC下来,大部分对象要保留,只有少数对象要回收,此时复制开销就很大了
3. 标记整理
- 类似于顺序表删除元素(搬运操作)。
即:将不是垃圾的对象往前搬运,可以直接占据垃圾的位置,最后就会形成一段连续使用的空间。 - 标记整理的方式相对于赋值算法来说:空间利用率提高了,也解决了空间碎片问题,但是搬运操作是比较耗时的!
4. 分代回收
- 上述的标记清除、复制算法、标记整理三个方法都不是尽善尽美的,所以我就需要根据实际场景因地制宜地解决问题。此时就引入了“分代回收”策略,将上面的办法进行综合。
- 分代回收:就是根据对象不同的特点来采取不同的回收方式。
- 对象的特点是根据对象的年龄来划分的,年龄是依据GC的轮次来算的。
有一组线程,周期性的扫描代码里所有的对象。如果一个对象经历了一次GC还没有被回收,此时就认为年龄+1
-
一个基本的经验规律:
如果一个对象寿命比较长,那它大概率会活得更久(要挂早挂了) -
基于经验规律,就针对对象的年龄进行了分类,把堆里的对象分成了:新生代(年龄小的对象,但是大概率会挂)和老年代(年龄大的对象,大概率寿命会更久一些)。
-
对于新生代,GC的扫描频率更高,因为挂的概率更大;而对于老年代,GC的扫描频率更低,因为挂的概率更小一些。
-
新生代中又分为:伊甸区和生存区、生存区。
-
刚创建出来的新对象进入伊甸区。
如果新对象熬过一轮GC没挂,就通过复制算法复制到生存区。
(每经过一轮GC,伊甸区的内容就会全部被清空:没挂的新对象被复制到生存区) -
生存区的对象也要经历GC的考验,每次熬过一轮GC,就会通过复制算法拷贝到另一个生存区中。
① 同一时刻,只有一个生存区在使用。
② 通过考验的被复制到另一个生存区,未通过和通过的最后都会在该生存区中被直接释放。
-
只要这个对象不消亡,就会在这两个生存区之间来回拷贝。
-
每一轮拷贝,每一轮GC都会筛选掉一大波对象。
如果一个对象在生存区中反复坚持了很多轮还没有被释放,此时就会进入老年代了。(轮数在不同版本的JVM中可能是不一样的,网上常见是15,但是不建议记住!面试也不要具体回答!) -
如果对象进入了老年代,其实并不意味着进入了保险箱,也是会定期进行GC的,只是频率更低了一些。
这里采取标记整理的方法来处理老年代的对象。 -
上述规则有一个特殊的情况:
如果对象是一个非常大的对象,则直接进入老年代。
理由:
① 大对象进行复制算法的开销太大
② 很大的对象是花费了很多资源创建出来的,所以一般情况下是不会立即进行销毁的。
【补充】
面试时说数字的情况:
要么就是通过自己合理实验得出的,或者是查阅权威文献(如JDK官方文档等)得出的严谨的结论,否则不要轻易说数字!
【简单回顾小结】
- 判定垃圾方式:
① 引用计数
② 可达性分析(主要记住GC roots有哪些对象) - 进行回收:
① 标记清除 -> 内存碎片
② 复制算法 -> 浪费空间
③ 标记整理 -> 类似于顺序表搬运元素,时间比较长
④ 分代回收 -> 因地制宜地完成回收
【补】垃圾回收器(了解,没时间跳过)
- “垃圾回收器”:JVM中具体的垃圾回收实现的模块(会应用到上述的回收策略)
- 垃圾回收器很多,但是主要了解CMS、G1和ZGC(Java15开始),这些目前还在使用。
CMS收集器(老年代收集器,并发GC)
- 特性:
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。 - CMS收集器是基于“标记—清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为4个步骤:
① 初始标记(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。
G1收集器(唯一一款全区域的垃圾回收器)
- G1(Garbage First)垃圾回收器是用在heap memory很大的情况下,把heap划分为很多很多的
region块,然后并行的对其进行垃圾回收。 - G1垃圾回收器在清除实例所占用的内存空间后,还会做内存压缩。
- G1垃圾回收器回收region的时候基本不会STW,而是基于 most garbage优先回收(整体来看是基于"标
记-整理"算法,从局部(两个region之间)基于"复制"算法) 的策略来对region进行垃圾回收的。 - 具体参考:G1回收器
ZGC收集器
参考:ZGC垃圾收集器
小结
- JVM内存划分:主要记住1.7之前(栈、堆、方法区、程序计数器)
- JVM类加载:加载、连接(验证、准备、解析)、初始化
- GC回收的是堆,回收单位是“对象”
- 判定某个对象是垃圾:引用计数、可达性分析
- 回收垃圾的策略:标记清除、复制算法、标记整理、分代回收
- 垃圾回收器(了解)