JVM
JVM结构
数据类型
jvm包括两种数据类型,基本类型和引用类型。
基本类型包括,数值类型,boolean类型,和returnAddress类型。
数值类型包括,整型,浮点型,和char类型。
boolean类型同样只有true和false。
returnAddress类型是一个指针,指向jvm指令的操作码,在Java中没有与之对应的类型。
boolean类型的操作会被转化为int类型的操作进行,boolean数组会当成byte数组去操作。1表示true,0表示false。
引用类型包括三种,类类型,数组类型,和接口类型。
它们的值是动态创建的类实例,数组,或实现接口的类实例。
数组有component类型和element类型,component类型就是数组去掉最外层维度后剩下的类型,可能还是一个数组类型(对于多维数组)。
堆
jvm有一个堆,在所有jvm线程间共享,堆是一个运行时数据区域,所有为类实例和数组分配的内存都来自于它。
堆在jvm启动时创建,堆中对象不用显式释放,gc会帮我们释放并回收内存。
虚拟机内最大的内存空间,目的是为了存储对象。
方法区
线程间共享的一片区域,存储类的信息,静态变量,常量,即时编译后的代码数据等。
类的信息:版本号,方法,接口
运行时常量池
运行时常量池就是类或接口的字节码文件里的常量池的运行时表示形式,它包含几种常量。
如在编译时就已经知道的数字字面量值,和必须在运行时解析的方法和字段的引用,运行时常量池的功能类似于传统语言的符号表,不过它包含的数据会更加宽泛。
运行时常量池分配在jvm的方法区,类或接口的运行时常量池在类或接口被jvm创建时才会构建。
特点:是方法区的一部分,受方法区内存限制,可能会抛OutOfMemoryError异常。
运行时私有数据区
pc寄存器(程序计算器)
jvm支持一次运行多个线程,每个线程都有自己的pc寄存器,任何时候一个线程只能运行一个方法的代码。
如果方法不是native的,pc寄存器包含当前正在被执行的jvm指令地址,如果方法是native的,pc寄存器的值是未定义的。
jvm栈
每一个jvm线程都有一个私有的jvm栈,随着线程的创建而创建,栈中存储的是帧。
jvm栈和传统语言如C的栈相似,保存局部变量和部分计算结果,参与方法的调用和返回。jvm栈主要用于帧的出栈和入栈,除此之外没有其它操作,
帧可能是在堆上分配的,所以jvm栈使用的内存不必是连续的。
native方法栈
native方法不是用Java语言写的,为了支持它需要使用传统栈,如C语言栈。不过jvm不能加载native方法,所以也不需要提供native方法需要的栈。
帧
每次当一个方法被调用时一个新的帧会被创建。当方法调用完成时,与之对应的帧会被销毁,无论是正常完成还是抛异常结束。
所以帧是方法调用的具体体现形式,或称方法调用是以帧的形式进行的。帧用来存储数据和部分计算结果,和执行动态链接,方法返回值,分发异常。
帧分配在创建帧的线程的jvm栈上,每一个帧都有自己的本地变量数组,自己的操作数据栈,和一个对当前方法所在类的运行时常量池的引用。
本地变量数组和操作数栈的大小在编译时就确定了,它们随着和帧关联的方法编译后的代码一起被提供,因此帧这种数据结构的大小只依赖于jvm的实现,这些结构所需的内存可以在方法调用时同时被分配。
在一个线程执行的任何时刻,都只会有一个帧是处于激活的。这个帧被称为当前帧,与之对应的方法被称为当前方法,方法所在的类被称为当前类,此时用到的本地变量数组和操作数栈也都是当前帧的。
一个帧将不在继续是当前帧,如果它的方法调用了另一个方法,或者它的方法结束了。
当一个方法被调用,一个新的帧被创建,当执行控制由原来的方法传递到新的方法时,这个新的帧变为当前帧。
当方法返回时,当前帧把方法执行的结果传回到上一帧,当上一帧被激活的同时当前帧会被丢弃。
方法调用
一个方法调用正常完成(即没有抛异常)时,会根据所返回的值的类型执行一个适合的return指令,当前帧会去恢复调用者的状态,包括它的本地变量和操作数栈,使调用者的程序计数器适合的递增来跳过刚刚的那个方法调用指令。
返回值会被放到调用者帧的操作数栈上,然后继续执行调用者方法的帧。
一个方法在调用时抛出了异常,且这个异常没有在这个方法内被捕获处理,将会导致这个方法调用的突然结束,这种情况下永远不会向方法的调用者返回一个值。
Java类库
jvm必须为Java类库的实现提供足够的支持。一些类库中的类如果没有jvm协助是无法实现的。
反射,就是在运行时获取某个类的类型相关信息,如它的字段信息,方法信息,构造函数信息,父类信息,实现的接口信息。
这些信息都必须是把一个类加载完之后才可以知道的,只有jvm才可以加载类。如java.lang.reflect这个包下的类和Class这个类。
在Java中加载一个类或接口用类加载器,即ClassLoader,背后还是委托给jvm来实现的。
链接和初始化一个类或接口。
安全,如java.security包下的类,还有其它类像SecurityManager。
多线程,如线程这个类Thread。
弱引用,像java.lang.ref包下的类。
Java对象的内存分配过程是如何保证线程安全的?
为了保证对象的内存分配过程中的线程安全性,HotSpot虚拟机提供了一种叫做TLAB(Thread Local Allocation Buffer)的技术。
在线程初始化时,虚拟机会为每个线程分配一块TLAB空间,只给当前线程使用,当需要分配内存时,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。
所以,“堆是线程共享的内存区域”这句话并不完全正确,因为TLAB是堆内存的一部分,他在读取上确实是线程共享的,但是在内存分配上,是线程独享的。
TLAB的空间其实并不大,所以大对象还是可能需要在堆内存中直接分配。那么,对象的内存分配步骤就是先尝试TLAB分配,空间不足之后,再判断是否应该直接进入老年代,然后再确定是再eden分配还是在老年代分配。
垃圾回收GC
引用计数法
啥叫引用计数法,简单地说,就是对象被引用一次,在它的对象头上加一次引用次数,如果没有被引用(引用次数为 0),则此对象可回收
存在的问题:如果对象间有循环引用的情况,会发生计数值永不为0,无法被回收
可达性算法
现代虚拟机基本都是采用这种算法来判断对象是否存活,可达性算法的原理是以一系列叫做 GC Root 的对象为起点出发,引出它们指向的下一个节点,再以下个节点为起点,引出此节点指向的下一个结点(这样通过 GC Root 串成的一条线就叫引用链),直到所有的结点都遍历完毕,如果相关对象不在任意一个以 GC Root 为起点的引用链中,则这些对象会被判断为「垃圾」,会被 GC 回收。
注意:a, b 对象可回收,就一定会被回收吗?并不是,对象的 finalize 方法给了对象一次垂死挣扎的机会,当对象不可达(可回收)时,当发生GC时,会先判断对象是否执行了 finalize 方法,如果未执行,则会先执行 finalize 方法,我们可以在此方法里将当前对象与 GC Roots 关联,这样执行 finalize 方法之后,GC 会再次判断对象是否可达,如果不可达,则会被回收,如果可达,则不回收!而且该方法只能被执行一次。如果第一次执行 finalize 方法此对象变成了可达确实不会回收,但如果对象再次被 GC,则会忽略 finalize 方法,对象会被回收!这一点切记!
GC ROOT是什么
1.虚拟机栈(栈帧中的本地变量表)中引用的对象
2.方法区中类静态属性引用的对象
3.方法区中常量引用的对象
4.本地方法栈中 JNI(即一般说的 Native 方法)引用的对象
垃圾回收的方法
标记清除
1.先根据可达性算法标记出相应的可回收对象(图中黄色部分)
2.对可回收的对象进行回收
问题:会造成回收后的内存不连续,操作系统再次分配内存时,会有内存碎片(内存浪费)
复制算法
把堆等分成两块区域, A 和 B,区域 A 负责分配对象,区域 B 不分配, 对区域 A 使用以上所说的标记法把存活的对象标记出来(下图有误无需清除),然后把区域 A 中存活的对象都复制到区域 B(存活对象都依次紧邻排列)最后把 A 区对象全部清理掉释放出空间,这样就解决了内存碎片的问题了。(先标记清除,再复制内存块)
问题:一半的内存始终无法利用,只能等着被复制算法操作,500m-250m。
标记整理
前面两步和标记清除法一样,不同的是它在标记清除法的基础上添加了一个整理的过程 ,即将所有的存活对象都往一端移动,再清理掉另一端的所有区域,这样的话就解决了内存碎片的问题。
问题:每次清除都需要对内存进行整理,效率低。
分代收集算法
分代收集算法整合了以上算法,综合了这些算法的优点,最大程度避免了它们的缺点,所以是现代虚拟机采用的首选算法
新生代/老年代 = 1/2
Eden:from survivor: to survivor = 8:1:1
分代收集工作原理
由以上的分析可知,大部分对象在很短的时间内都会被回收,对象一般分配在 Eden 区。
当 Eden 区将满时,触发 Minor GC。
我们之前怎么说来着,大部分对象在短时间内都会被回收, 所以经过 Minor GC 后只有少部分对象会存活,它们会被移到 S0 区(这就是为啥空间大小 Eden: S0: S1 = 8:1:1, Eden 区远大于 S0,S1 的原因,因为在 Eden 区触发的 Minor GC 把大部对象(接近98%)都回收了,只留下少量存活的对象,此时把它们移到 S0 或 S1 绰绰有余)同时对象年龄加一(对象的年龄即发生 Minor GC 的次数),最后把 Eden 区对象全部清理以释放出空间。
当触发下一次 Minor GC 时,会把 Eden 区的存活对象和 S0(或S1) 中的存活对象(S0 或 S1 中的存活对象经过每次 Minor GC 都可能被回收)一起移到 S1(Eden 和 S0 的存活对象年龄+1), 同时清空 Eden 和 S0 的空间。
若再触发下一次 Minor GC,则重复上一步,只不过此时变成了 从 Eden,S1 区将存活对象复制到 S0 区,每次垃圾回收, S0, S1 角色互换,都是从 Eden ,S0(或S1) 将存活对象移动到 S1(或S0)。也就是说在 Eden 区的垃圾回收我们采用的是复制算法,因为在 Eden 区分配的对象大部分在 Minor GC 后都消亡了,只剩下极少部分存活对象(这也是为啥 Eden:S0:S1 默认为 8:1:1 的原因),S0,S1 区域也比较小,所以最大限度地降低了复制算法造成的对象频繁拷贝带来的开销。
对象何时晋升老年代
1.当对象的年龄达到了我们设定的阈值,则会从S0(或S1)晋升到老年代(比如阈值15)
2.当对象是大对象时,如果还在eden区分配内存的话,回收起来采用复制算法效率很低。会快速占满S0,S1。
3. 还有一种情况也会让对象晋升到老年代,即在 S0(或S1) 区相同年龄的对象大小之和大于 S0(或S1)空间一半以上时,则年龄大于等于该年龄的对象也会晋升到老年代。
空间分配担保
在发生 MinorGC 之前,虚拟机会查看老年代内存连续存储空间是否大于新生代的,如果大于,那么MinorGC是安全的。如果不大于,虚拟机会查看设置,如果设置允许担保失败,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,则继续进行MinorGC,否则进行full GC。
Stop The World(STW)
如果老年代满了,会进行FULL GC,会导致STW,性能开销很大。
STW:挂起当前所有线程的工作,只进行FULL GC,会造成线程停顿时间过长,请求等待时间过长,所以尽量减少FULL GC。
总结:由于 Full GC(或Minor GC) 会影响性能,所以我们要在一个合适的时间点发起 GC,这个时间点被称为 Safe Point,这个时间点的选定既不能太少以让 GC 时间太长导致程序过长时间卡顿,也不能过于频繁以至于过分增大运行时的负荷
垃圾收集器的种类
新生代
Serial 收集器
工作在新生代,单线程工作,只会使用一个CPU或者或者线程进行垃圾收集,他在垃圾收集时,会造成STW。
所以一般在客户端使用,Serial 单线程模式无需与其他线程交互,减少了开销,专心做 GC 能将其单线程的优势发挥到极致,另外在用户的桌面应用场景,分配给虚拟机的内存一般不会很大,收集几十甚至一两百兆,STW 时间可以控制在一百多毫秒内,只要不是频繁发生,这点停顿是可以接受的,所以对于运行在 Client 模式下的虚拟机,Serial 收集器是新生代的默认收集器。
ParNew 收集器
该收集器是Serial的多线程版本,回收策略相同,在底层共用了很多代码。也存在STW。
一般是用于服务端,当服务端接受大量请求时,响应就很重要,多线程可以让垃圾回收得更快,也就是减少了 STW 时间。只有它能与 CMS 收集器配合工作。
Parallel Scavenge 收集器
采用复制算法的多线程收集器,与CMS不同的是该收集器的目的不是减少STW时间,而是达到一个可控制的吞吐量(吞吐量 = 运行用户代码时间 / (运行用户代码时间+垃圾收集时间)),如99%的时间执行线程,1%的时间执行垃圾回收,吞吐量=99%。
Parallel Scavenge 收集器提供了两个参数来精确控制吞吐量,分别是控制最大垃圾收集时间的 -XX:MaxGCPauseMillis 参数及直接设置吞吐量大小的 -XX:GCTimeRatio(默认99%)
除了以上两个参数,还可以用 Parallel Scavenge 收集器提供的第三个参数 -XX:UseAdaptiveSizePolicy,开启这个参数后,就不需要手工指定新生代大小,Eden 与 Survivor 比例(SurvivorRatio)等细节,只需要设置好基本的堆大小(-Xmx 设置最大堆),以及最大垃圾收集时间与吞吐量大小,虚拟机就会根据当前系统运行情况收集监控信息,动态调整这些参数以尽可能地达到我们设定的最大垃圾收集时间或吞吐量大小这两个指标。自适应策略也是 Parallel Scavenge 与 ParNew 的重要区别!
老年代
Serial Old 收集器、Parallel Old 收集器
与上面相似只是针对的是老年代
CMS 收集器
也是多线程的,为实现最短STW为目标的收集器。用标记-清除算法实现的。如果重视服务器响应速度,CMS是很好的选择。
过程:
1.初始标记
2.并发标记
3.重新标记
4.并发清除
只有初始标记和重新标记能造成STW,初始标记仅标记GC ROOT能关联的对象,而重新标记是标记再并发标记过程中因用户线程并行工作产生的关联对象,所以这两个两个标记的速度都很快。
而最耗时的是并发标记和并发清除,而这两个阶段又是和用户线程一起并行工作的,所以可以看做整个工作流程中CMS垃圾收集和用户线程一起并行工作的。
不足:
1. CMS 收集器对 CPU 资源非常敏感 ,CMS垃圾回收需要的线程是(CPU+3)/4。
2.CMS 无法处理浮动垃圾,在并发清除是也可能有对象垃圾产生。
3.采用标记清除算法,会造成大量内存碎片。
G1收集器
G1 收集器是面向服务端的垃圾收集器,被称为驾驭一切的垃圾回收器,有以下几个特点
像 CMS 收集器一样,能与应用程序线程并发执行。
整理空闲空间更快。
需要 GC 停顿时间更好预测。
不会像 CMS 那样牺牲大量的吞吐性能。
不需要更大的 Java Heap
与 CMS 相比,它在以下两个方面表现更出色
1.运作期间不会产生内存碎片,G1 从整体上看采用的是标记-整理法,局部(两个 Region)上看是基于复制算法实现的,两个算法都不会产生内存碎片,收集后提供规整的可用内存,这样有利于程序的长时间运行。
2.在 STW 上建立了可预测的停顿时间模型,用户可以指定期望停顿时间,G1 会将停顿时间控制在用户设定的停顿时间以内。
为什么G1能建立可预测的内存模型?
G1 各代的存储地址不是连续的,每一代都使用了 n 个不连续的大小相同的 Region,每个Region占有一块连续的虚拟内存地址
除了和传统的新老生代,幸存区的空间区别,Region还多了一个H,它代表Humongous,这表示这些Region存储的是巨大对象(humongous object,H-obj),即大小大于等于region一半的对象,这样超大对象就直接分配到了老年代,防止了反复拷贝移动。那么 G1 分配成这样有啥好处呢?
传统的收集器如果发生 Full GC 是对整个堆进行全区域的垃圾收集,而分配成各个 Region 的话,方便 G1 跟踪各个 Region 里垃圾堆积的价值大小(回收所获得的空间大小及回收所需经验值),这样根据价值大小维护一个优先列表,根据允许的收集时间,优先收集回收价值最大的 Region,也就避免了整个老年代的回收,也就减少了 STW 造成的停顿时间。同时由于只收集部分 Region,可就做到了 STW 时间的可控。
工作流程:
1.初始标记
2.并发标记
3.最终标记
4.筛选回收
可以看到整体过程与 CMS 收集器非常类似,筛选阶段会根据各个 Region 的回收价值和成本进行排序,根据用户期望的 GC 停顿时间来制定回收计划。