JVM
JVM内存区域:
- 线程私有:虚拟机栈、本地方法栈、程序计数器
- 线程共享:堆、方法区、直接内存
程序计数器:
- 线程私有,其生命周期和线程相同,每个线程都有属于自己的程序计数器
- 程序计数器是当前线程所执行的字节码的行号指令器,字节码解释器通过改变这个行号指令器的值来判断下一条需要执行的字节码指令
- 分支、循环、跳转、异常处理、线程恢复都需要依赖程序计数器完成
- 程序计数器是唯一不会出现OOM的地方
虚拟机栈:
- 线程私有,其生命周期和线程相同,每个线程都有属于自己的虚拟机栈
- 虚拟机栈是由栈帧组成的,栈帧包括:
- 局部变量表(基本类型、引用类型)
- 操作数栈(存储运算结果和运算的操作数,使用压栈和出栈的方式访问)
- 动态链接(指向「方法区」中「运行时常量池」的引用)
- 方法出口(方法退出后,回到字节码所执行到的位置)
- 每一次执行方法和退出方法(return、抛出异常)对应着每一次入栈和出栈的过程
- 虚拟机栈会发生OOM(堆中没有空闲内存,并且GC无法提供更多的内存就会OOM)
- 虚拟机栈会发生StackOverFlow(JVM中虚拟机栈不能动态扩展,当申请的栈深度大于虚拟机栈最大深度就会StackOverFlow)
- 有两种方法实现动态扩展:
- Segmented Stack:可以简单理解成一个双向链表把多个栈连接起来,一开始只分配一个栈,这个栈的空间不够时,就再分配一个,用链表一个一个连起来
- Stack Copying:在栈内存不够的时候,分配一个更大的栈,然后把原来的栈复制过去
- 有两种方法实现动态扩展:
本地方法栈:
- 线程私有,其生命周期和线程相同,每个线程都有属于自己的本地方法栈
- 本地方法栈结构和虚拟机栈相同,只不过在native本地方法执行时才会入栈和出栈,其余特点和虚拟机栈相同
- 本地方法栈会发生OOM、StackOverFlow
堆区域:
- 线程共享,其生命周期和进程相同,每个进程都有属于自己的堆区域
- 堆区域是JVM中内存最大的一块,GC的主要区域,JVM启动时会创建堆区域
- 几乎(内存逃逸技术:对象实例和数组可能直接分配在栈中)所有新创建的对象(对象实例、数组)都在堆区域进行分配
- 堆区域可以分成「新生代」(Eden、From Survivor(S0)、To Survivor(S1))和「老年代」,进一步的划分是为了更好的进行GC和分配内存,因为「新生代」的垃圾回收器都使用的「标记-复制」算法,所以在「堆内存」划分中,将新生代划分出一个Survivor区(S0、S1),目的是为了有一块完整的内存空间供垃圾回收器进行拷贝(移动)
- 大部分情况下,对象首先会在新生代进行分配,在新一轮GC后,存活下来的对象就会转移到From Survivor(S0) 或者 To Survivor(S1) 并将对象年龄设置为 1 ,当年龄到达15(可以在-XX:MaxTenuringThreshold 设置阈值)将对象转移到老年代
- 堆是最容易出现OOM的区域
-
OutOfMemoryError: GC Overhead Limit Exceeded:堆空间中进行GC后只能回收很少的空间
-
java.lang.OutOfMemoryError: Java heap space:新创建对象时,堆中内存无法存放新创的对象
- 堆区域默认比例:
- 年轻代:老年代 = 1 :2
- Eden:S0:S1 = 8:1:1
- 堆区域默认比例:
-
堆区域内存中对象的分配策略:
- 对象优先分配在Eden区
- 大对象直接进入老年代(JVM认为大对象存活时间一般较久)
- 长期存活的对象(年龄达到15)将进入老年代
- Survivor的S0、S1区不够分配,对象直接进入老年代
方法区:
- 线程共享,其生命周期和进程相同,每个进程都有属于自己的方法区
- 方法区用于存储:
- 已被JVM加载的类信息
- 常量
- 静态变量
- 即时编译器JIT编译后的代码
- 运行时常量池
- 方法区中有一个运行时常量池,用于存放静态编译时产生的字面量和符号引用,该常量池具有动态性,也就是说常量并不一定是编译时确定,运行时生成的常量也会存在于这个常量池中
- 方法区内偶尔进行GC,GC主要是对方法区里的「常量池」和「类型Class」的卸载
- 在1.8之前的HotSpot中,方法区又称为永久代
- 方法区是JVM设计规范的一部分,不同版本的JVM实现方法区的方式是不同的,所以说方法区类似接口,永久代类似实现类
- HotSpot默认使用永久代实现方法区。1.8之前可以设置方法区(永久代)的初始大小和最大值,1.8之后将方法区完全移除,转变为元空间metaspace(使用的是直接内存),可以设置其初始大小和最大值
- 方法区会发生OOM
为什么要用元空间(metaspace)替代方法区(永久代):
- 永久代有一个JVM本身固定的大小上限,而元空间使用的是直接内存,直接受本机系统可用内存的大小,OOM(java.lang.OutOfMemoryError: MetaSpace )的概率小很多(可以设置最大元空间大小,默认为unlimited也就是最大为本机内存)
- 方法区需要存放已经加载的类信息,JVM加载的class的总数,方法的大小都很难确定,因此难以对永久代的大小具体指定,容易出现OOM,而「元空间」加载多少类就不用受「永久代」的MaxPermSize所控制了,只受本机系统内存的限制
- 字符串常量池在1.7之前存在于永久代中,在大量使用字符串的情况下,非常容易出现OOM的异常
运行时常量池:
-
运行时常量池是方法区的一部分,.class文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(用于存放编译时生成的各种字面量和符号引用)运行时常量池也会OOM。
- 1.7之前运行时常量池包括了字符串常量池存放在方法区,此时HotSpot对于方法区的实现(永久代)
- 1.7时字符串常量池被移动到堆区域。运行时常量池中除字符串常量池以外还留在方法区,也就是HotSpot的方法区(永久代)
- 1.8之后 HotSpot移除方法区(永久代),元空间将其替代,这时字符串常量池还在堆区域中,运行时常量池从方法区移动到元空间中
总结:
- 1.7之前:运行时常量池(含字符串常量池) <-> 方法区(永久代)
- 1.7:运行时常量池(不含字符串常量池)<-> 方法区(永久代),字符串常量池 <-> 堆区域
- 1.8之后:运行时常量池(不含字符串常量池)<-> 元空间 , 字符串常量池 <-> 堆区域
直接内存:
- 1.8之后「元空间」使用的就是直接内存。直接内存不是虚拟机运行时数据区的一部分,也不是虚拟机设计规范的内存区域,但是会频繁的使用,会出现OOM,直接内存的分配不会收到JVM堆区域大小的限制,只受本机系统内存的限制
Java对象的创建过程:
- 类加载检查:虚拟机遇到一个new命令时, 会先检查这个命令的参数是否在「方法区的常量池」中定位到这个类的符号引用,随后检查这个符号引用是否已经被加载、解析和初始化过,如果没有,必须先经过类加载过程
- 分配内存:
- 类加载检查过后,会在堆区域中划分一个内存用于分配此新生对象
- 分配方式:
- 指针碰撞(适用于内存规整情况)
- 空闲列表(适用于内存不规整情况)
- 分配内存必须保证线程安全,JVM采用(CAS+失败重试)(不加锁,假设不会被其他线程影响,如果影响了就一直重试,重试了没有影响为止。TLAB)来保证分配内存的线程安全
- 初始化零值:分配好内存之后,将分配的内存空间都设置为0,保证了没有赋予初始值也可以直接使用
- 设置对象头:初始化零值之后,对分配的内存进行必要的设置,设置新对象的哈希码、 GC 信息、锁信息、对象所属的类元信息等
- 执行init方法:init方法执行前对象的字段都为零,执行init才会按new构造器中方法进行初始化(初始化才会给对象的成员变量赋予具体值)
总结:
类加载检查 -> 堆中分配内存 -> 初始化零值 -> 设置对象头 -> 执行init方法(按new构造器方法进行初始化)
对象从加载到JVM,再到被GC清除,经历了哪些过程:
- 创建一个对象,JVM首先需要到方法区(元空间)找到对象的类型信息,然后再创建对象
- JVM要实例化一个对象,首先要在堆中先创建一个对象
- 对象首先会分配在堆内存中新生代的Eden,然后经过一次Minor GC,对象如果存活,会转移到S0区,在后续的每次GC过程中,如果对象一直存活,就会在S0区和S1区来回拷贝,每移动一次,年龄age + 1,当年龄大于15,对象转移到老年代
- 当对象的方法执行结束后,此线程的虚拟机栈会弹出,栈中的指针会先移除掉
- 堆中的对象,经过Full GC,就会被标记为垃圾,然后被GC线程清理掉
总结:
类加载检查 -> 堆中创建对象 -> 堆中区域拷贝(移动) -> 对象的方法执行结束,虚拟机栈出栈,指针移除 -> GC清除
对象的访问定位方式:
-
建立对象就是为了使用对象,我们在栈上通过引用类型指向的地址来操作堆区域的具体对象,有两种访问定位方式,前者稳定性好,后者速度快
- 句柄指针(间接指向):
- 堆区域会额外开辟一个空间用于存放句柄,而栈中的引用都是指向对象的句柄地址
- 句柄又包含了对象的实例数据和对象类型数据的各自具体地址
- 直接指针(直接指向):栈中的引用直接指向对象的地址
GC的分类:
- 新生代收集(Minor GC / Young GC)
- 老年代收集(Major GC / Old GC)
- 混合收集(Full GC):新生代与老年代都收集
GC的时机:
- 堆中的Eden区满了之后,会触发Minor GC。存活下来的对象进入S0或者S1区,并在其中反复拷贝,如果S0和S1区满了,对象直接进入老年代
- 堆中的老年代满了,以及方法区(永久代)内存不足,JVM内存不足会触发Full GC
如何判断对象已经死亡:
- 引用计数法:给对象一个引用计数器,每次被引用,引用计数器都会+1、引用失效,引用计数器会-1。一般不使用,可能会出现循环引用
- 可达性分析法:以一系列称作GC ROOT的节点开始,从上向下搜索,节点所走过的路径为引用链,当一个对象和任何GC ROOT没有任何引用链的话,那么这个对象就是不可用的(JVM使用的是可达性分析法)。所以GC ROOT一定是一个「活跃」的引用链,从GC ROOT出发,程序通过直接引用或者间接引用,能够找到可能正在被使用的对象
- GC ROOT对象:
- 方法区中「类静态变量」所引用的对象
- 方法区中「常量池」所引用的对象
- 虚拟机栈中「栈桢内局部变量表」中的对象引用指向的堆中的对象
- 本地方法栈中Native方法所引用的对象
- 死缓:
- 当一个对象不可达GC ROOT时,这个对象并不会立马被回收,而是出于一个死缓阶段,若要被真正回收需要经历两次标记,在必要时执行finalize()方法
- 总结:GC ROOT是一组必须「活跃」的「引用」,只要跟GC ROOT没有直接或间接引用相连的对象,就是垃圾
- GC ROOT对象:
如何判断一个常量是废弃常量:
- 运行时常量池主要回收的是废弃的常量,加入字符串“abc”在常量池中,如果没有引用指向它的话,就说明“abc”是废弃常量
- 如果发生内存回收且有必要的话,“abc”会被清理出常量池
如何判断一个类是无用的类:
- 方法区(类信息)主要回收的是无用的类,一个类必须满足三个条件才能算是无用的类,满足下列三个条件也不是一定就会被回收,只是达到回收条件
- 该类的所有对象都已经被回收,即堆区域不存在此类的实例对象
- 加载该类的「ClassLoader」已经被回收
- 该类的对应的堆中的「java.lang.Class」对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
强引用、软引用、弱引用、虚引用:
- 无论我们使用引用计数法,还是可达性分析法判断对象已经死亡,都是需要与引用有关系,「软引用」用的最多
- 强引用:如果一个对象具有强引用,垃圾回收器绝对不会回收它,当内存不足时,宁愿堆区域OOM也不愿意回收
- 软引用:如果一个对象具有软引用,在内存空间充足的情况下,垃圾回收机不会回收它,但在内存空间不足情况下,垃圾回收机就会回收它
- 弱引用:如果一个对象具有弱引用,那么它只能存活在下一次垃圾回收前,也就是只能存活最多一个垃圾回收周期
- 虚引用:如果一个对象具有虚引用,那么跟没有引用一样,任何时候都可能会被垃圾回收,但跟上述引用都不同,它只是作为一个垃圾回收对象的追踪器,虚引用必须跟「引用队列」联合使用,当垃圾回收器准备回收一个带有虚引用的对象时,会将这个虚引用加入与之关联的「引用队列」中去,这就可以通过「引用队列」中是否加入其虚引用,来判断是否这个对象准备被垃圾回收掉,这样就可以在垃圾回收之前执行一些特定的操作。「引用队列」中有某对象的虚引用,说明此对象准备被GC
垃圾回收算法:
-
标记-清除算法:
分为标记和清除两阶段:
- 标记:首先标记出 「不需要回收」的对象
- 清除:然后把所有「没有被标记」的区域全部回收掉
问题:
- GC效率低下
- 产生内存碎片
-
复制算法:
- 将内存分为两个大小相同的块,每次使用其中一块
- 当一块内存全部使用完后,将还存活的对象复制到另一块区
- 然后对原来使用的空间一次性清理掉,这样每次对内存回收都是对内存区域的一半进行回收
-
标记-整理算法:
- 分为标记和整理两阶段,首先标记出「不需要回收」的对象,
- 然后不是直接进行回收,而是所有「存活的对象」也就是「被标记」的对象全部移动到一端去
- 最后直接清理掉上述端边界以外的内存
-
分代收集算法:
- JVM的GC都采用分代收集算法
- 一般将堆区域分为新生代和老年代,根据各个年代特点选择合适的收集算法
- 新生代:每次GC都有大量对象死去,采用复制算法
- 老年代:对象存活几率较高,采用标记-清除、标记-整理算法
HotSpot为什么要采用新生代和老年代(为什么采用分代回收):
- 大部分对象都死的早,只有少部分对象会存活很长时间。在堆内存上都会在物理或逻辑上进行分代,为了使「Stop The World」持续时间尽可能短以及提高并发式GC所能应付的内存分配速率
- 为了提高并发GC效率
- 为了减少GC时Stop The World的时间
垃圾收集器:
如果说垃圾回收算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体体现
- JDK1.8默认收集器:
- 新生代—Parallel Scavenge(多线程收集器,高吞吐,复制算法)
- 老年代—Parallel Old(多线程收集器,高吞吐,标记-整理算法)
- JVM中,有一个垃圾回收线程,它是低优先级的,在正常情况下是不会执行的,只有在虚拟机空闲或者当前堆内存不足时,才会触发执行,扫描那些没有被任何引用的对象,并将它们添加到要回收的集合中,进行GC
GC中的并行与并发:
-
并行(Parallel):多条GC线程并行工作,用户线程仍然处于等待状态
-
并发(Concurrent):GC线程与用户线程同时执行(不一定是传统意义线程的并行,可以是交替执行),GC线程在运行,用户线程在另一个CPU运行
-
新生代收集器:Serial(单线程)、ParNew(多线程)、Parallel Scavenge(多线程、高吞吐)
-
老年代收集器:Serial Old(单线程)、ParNew Old(多线程)、Parallel Scavenge Old(多线程、高吞吐)、CMS(多线程、并发执行)
-
新老代收集器:G1(多线程、并发执行)
-
Serial收集器:单线程收集器,只会使用一个GC线程回收内存,并且在GC线程运行时,会Stop The World(让用户线程全部停止,只有GC线程运行,防止用户线程继续修改引用)直到收集结束
- 新生代—复制算法(Serial收集器),老年代—标记-整理算法(Serial Old收集器)
- 优点:简单而高效,没有切换线程带来的开销
- 缺点:Stop The World 影响用户体验
- Client模式不错的选择
-
ParNew收集器:多线程收集器,Serial收集器的多线程版本,会使用多个GC线程回收内存,并且在GC线程运行时,会Stop The World 直到收集结束
- 新生代—复制算法(ParNew收集器),老年代—标记-整理算法(ParNew Old收集器)
- 优点:简单而高效,没有切换线程带来的开销
- 缺点:Stop The World 影响用户体验
- Server模式不错的选择,只有ParNew可以和CMS收集器(真正意义的并发收集器)配合工作
-
Parallel Scavenge收集器:多线程收集器,与ParNew收集器类似,只对新生代进行收集,更关注CPU吞吐量(CPU运行用户代码与CPU总消耗时间比值)
- 新生代—复制算法(Parallel Scavenge收集器),老年代—标记-整理算法(Parallel Old收集器)
-
CMS收集器:真正意义上的并发收集器,使得GC线程与用户线程同时执行
-
老年代—标记-清除算法
-
运行过程:
- 初始标记:(暂停所有线程),标记出「直接」与GC ROOT相连的对象,速度很快
- 并发标记:(同时开启GC线程和用户线程),并从GC ROOT继续向下标记(初始标记只标记了直接相连的对象)但是用户线程会更新对象的引用域,也就是GC ROOT相连的引用链会也在改变
- 重新标记:(仅开启GC线程)为了修正因「并发标记」阶段因为用户线程继续执行而导致标记产生变动的那一部分对象的标记记录。这个阶段停顿时间比初始标记长,比并发标记短
- 并发清除:(同时开启GC线程和用户线程)开启用户线程,同时GC线程对标记的对象进行回收
Time: t(初始标记)< t(重新标记) < t(并发标记)
-
优点:并发收集、低延迟
-
缺点:
- 采用「标记-清除」算法,会产生「碎片」
- 无法处理「浮动垃圾」,并发清除过程中,用户线程还在执行,还会产生垃圾,这些垃圾只能等到下一次GC
- 对CPU比较敏感,并发标记和并发清除是GC线程和用户线程并发执行,会导致程序变慢,吞吐量降低
-
-
G1(Garbage First)收集器:唯一一个可以同时用于新生代和老年代的收集器,使用方法复杂
- 整体上—标记-整理算法,局部上—通过复制算法实现
- 运行过程:
- 初始标记:与CMS收集器初始标记相同
- 并发标记:与CMS收集器并发标记相同
- 最终标记:将并发标记中对象的变化记录在Remenbered Set Logs里面,最终把其合并为Remenbered Set(G1后台维护的优先列表)中,
- 筛选回收:对G1后台维护的优先列表中每一个Region的价值和成本进行筛选,根据用户期望的GC停顿时间,得到最好的回收方案进行回收
- 优点:并发性强、分代收集、无内存碎片
- 缺点:使用方法复杂,定制化程度太高
JVM类加载机制:
JVM把描述类的数据从.class文件(一组以8个字节为基础单位的二进制字节流)加载到内存,并对数据进行链接(验证、准备、解析)和初始化,最终形成可以被JVM直接使用的Java类型
- 加载 -> 链接 (验证,准备,解析)-> 初始化
- 加载:
- 通过类的完全限定名,产生一个代表该类型的「二进制字节流」(实现这个动作的模块为「类加载器」)
- 解析这个二进制字节流的「静态存储结构」将其转化为「方法区内运行时的数据结构」
- 在「堆区域」中生成一个java.lang.Class类的对象实例,作为「方法区」这个类的各种数据的访问接口
- 验证:
- 确保.class文件的字节流包含的信息符合当前JVM的需求,并且不会危害到JVM自身安全
- 准备:
- 在「方法区」中,给类变量(static)分配内存,并设置为初始值(非static实例变量不会被分配内存,非static变量是在对象实例化时随着对象一起被分配至堆)(public static int value = 0,准备阶段value初始值为0,在初始化阶段才会变成具体值)
- 解析:
- JVM将「常量池」内的「符号引用」替换为「直接引用」
- 初始化:
- 开始执行真正的java代码,执行clinit()方法,该方法会收集所有类变量(static)的「赋值动作」和静态语句块(static)合并产生,首先执行父类
- 加载:
JVM类加载时机:
- 遇到new、getstatic 和 putstatic 或 invokestatic 这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化
- 对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化
- 当初始化类的父类还没有进行过初始化,则需要先触发其父类的初始化
- 虚拟机启动时,用户需要指定一个要执行的类(main)JVM会优先初始化这个类
类加载器:
- 类加载机制中的加载阶段中,通过类的完全限定名,产生一个代表该类型的二进制字节流的动作称为「类加载器」。将 class 文件二进制数据放入方法区内,然后在 堆区域 内创建一个 java.lang.Class 对象,Class 对象封装了类在方法区内的数据结构,并且向开发者提供了访问方法区内的数据结构的接口
- 类加载器负责读取Java字节码文件,并转换成java.lang.Class类的一个实例
- 类加载器都是java.lang.ClassLoader的子类
双亲委派模型:
- 如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载
- 启动类加载器(Bootstrap ClassLoader):用来加载Java核心类库,C++实现,是虚拟机自身的一部分,负责加载放置在<JAVA_HOME>\lib下的jar包和class文件
- 扩展类加载器(Extension ClassLoader):用来加载Java扩展库,为 Java 语言实现的,均继承自抽象类 java.lang.ClassLoader,负责加载放置在<JAVA_HOME>\lib\ext下的jar包和class文件
- 应用类加载器(Application ClassLoader):用来加载Java应用的类,为 Java 语言实现的,均继承自抽象类 java.lang.ClassLoader,如果类没有自定义过自己的类加载器,默认使用应用类加载器进行类加载
- 双亲委派模型对于保证 Java 程序的稳定运作很重要,例如类 java.lang.Object,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的「启动类加载器」进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类
- 双亲委派机制的实现:
- 检查一下指定名称的类是否已经加载过,如果加载过了,就不需要再加载,直接返回
- 如果此类没有加载过,那么,再判断一下是否有父加载器;如果有父加载器,则由父加载器加载(即调用parent.loadClass(name, false);).或者是调用bootstrap类加载器来加载
- 如果父加载器及bootstrap类加载器都没有找到指定的类,那么调用当前类加载器的findClass方法来完成类加载