Java内存区域
2.1、运行时数据区域
1、程序计数器
- 当前线程所执行的字节码的行号指示器
- 字节码解释器工作时就是通过改变这个计数器的值来选取吓一跳需要执行的字节码指令。
- 是线程私有内存,每条线程都有一个独立的程序计数器。
作用:
- 若正在执行的一个java 方法,那么这个计数器记录的是正在执行的虚拟机字节码指令的地址。
- 若执行的是native方法,则为undefined,为空。
2、Java虚拟机栈
- 也是线程私有,生命周期与线程相同。
- 虚拟机栈描述的是Java方法执行的线程内存模型。
- 每个方法执行的时候,Java虚拟机都会创建一个栈帧,来存储局部变量表,操作数栈,动态链接,方法出口等信息。
- 每个方法被调用到执行完毕的过程,都对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
3、本地方法栈
虚拟机栈是运行Java方法(字节码),本地方法栈运行本地方法 (native)
HotSpot将两者合二为一。
4、Java 堆
目的:只存放对象实例
- 各线程共享的区域
- 是垃圾收集器管理的内存区域
- 可以处于不连续的内存空间中
- 可以是固定大小,亦可可扩展。
5、方法区
- 线程共享
- 存储被虚拟机加载的类型信息,常量,静态变量等。
永久代与方法区
HotSpot用永久代实现方法区,目的是Java垃圾收集器像管理堆一样管理此区域。
**弊端:**导致Java程序容易遇到内存溢出。
JDK6开始,hotspot准备放弃永久代,改用本地内存实现方法区。
JDK7,将字符串常量池,静态变量移除。
JDK8、完全放弃,本地内存的元空间实现方法区。
6、运行时常量池
方法区一部分,常量池表,存放编译期生成的各种字面量,符号引用。
2.2、对象创建
2.2.1、对象创建的流程
流程:
当Java虚拟机遇到一条字节码new指令时,
-
类加载检查
- 首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并 检查 这个符号引用代表的类是否已被加载,解析,初始化过,没有,执行类加载过程。
-
分配内存
- 为对象分配内存实际上在类加载的时候便确定,内存分配就是将一块同等大小的内存从堆中分出来。
- 指针碰撞
分配内存时如何保证线程安全?
- 虚拟机采用CAS + 失败重试方式 保证更新操作的原子性。
- 把内存分配动作按照 线程 划分在不同的空间之中进行。即 每个线程在Java堆中预先分配一小块内存,本地线程分配缓冲。
-
初始化
- 保证对象的实例字段在Java代码中可以不赋值使用,初始化默认的零值。
-
设置对象头
到此,Java虚拟机认为一个新对象已经产生,但是从Java程序来说,创建对象才刚开始。
new指令之后会接着执行方法,进行对象初始化。
2.2.2、对象的内存布局
1、对象头
包括两类信息,
- 第一类是存储对象自身运行时的数据,如哈希码,GC分代年龄,锁状态,线程持有的锁
- 第二类是类型指针,即 对象指向它的类型元数据的指针。Java虚拟机通过这个指针确定它是哪个类的实例。
- 查找对象元数据信息不一定需要经过对象本身。
- 若对象是个数组,对象头必须有一块地方用于记录数组长度的数据。因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但若数组长度不确定,将无法通过元数据信息推断数组的大小。这也就说明了我们初始化数组时为何必须指定长度或指定元素了。
2、实例数据
对象真正存储的有效信息。即 我们在程序代码中所定义的各种字段内容。无论是父类继承,还是子类定义的字段都必须记录下来。
存储顺序会受到虚拟机分配策略参数,以及字段在Java源码中定义的顺序的影响。
- 相同宽度的字段会被放在一起存放
- 父类中变量会出现在子类之前。
3、对齐填充
不是必然存在的。
2.2.3、对象的访问定位
Java虚拟机通过栈上的引用对象操作堆上的对象实例。
主流的虚拟机的访问方式主要使用句柄和直接指针两种。
- 使用句柄,Java堆中可能划分出一块内存作为句柄池,引用中存储的就是对象的句柄地址。而句柄中包含了实例数据及类型数据的地址。
- 使用直接指针访问,引用直接指向实例数据,实例数据中存有指向类型数据的指针。
前者在对象移动时只需改变句柄处实例数据指针,而引用本身不需要修改。
后者速度更快,节省依次开销。HotSpot直接指针访问。
2.3、异常
除程序计数器之外,虚拟机内存的其他几个区域都可能发生内存溢出。
1.OutOfMemeryError
OOM
2.3.1、堆内存溢出
不断创建对象,就会有这个异常了。
2.3.2、虚拟机栈和本地方法栈溢出
由于HotSpot中并不区分两者,所以-Xoss(设置本地方法栈大小)虽然存在,但无效果。栈容量只能用-Xss设定。
- 若线程请求的栈深度大于虚拟机允许的最大深度,会抛出StackOverflowError异常。
- 若虚拟机允许内存动态扩展,当扩展栈无法申请到足够内存时,会抛出OutOfMemeryError异常。
无论是栈帧太大,还是虚拟机栈容量太小,当新的栈帧内存无法分配的时候,都会抛出栈溢出。
2.3.3、方法区和运行时常量池溢出
JDK1.6 及以前,方法区是由永久代实现的,所以我们可以指定永久代的大小,并且字符串常量池也存在于方法区里。
我们通过一致向池中增加字符串常量,可以轻易得到OOM
String 的 intern()方法
JDK1.6 把首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代的这个字符串的引用。而StringBuilder创建的字符串对象实例是在Java堆上,所以必不能是同一个引用,不相等。
JDK1.7不需要拷贝,因为字符串常量池已经移到Java堆中,故只需要记录下引用地址即可。只需要在常量池里记录下首次出现的实例引用即可。之后与之相同的直接将常量地址赋给引用。
GC垃圾收集器与内存分配策略
3.1、对象死了吗?
哪些对象被使用,哪些对象已经不再被使用了。
3.1.1、引用计数算法
在对象中添加引用计数器,有一个地方引用他,计数器加一,引用失效,计数器减一,这种办法是有缺陷的。
主流Java虚拟机都没有用引用计数算法来管理内存。
举个简单的栗子:两个对象互相引用,他们的引用计数器都为1,但是他们不再被使用,引用计数算法就无法回收它们。
3.1.2、可达性分析算法
基本思路:
- 通过一系列称为“GC Roots”的跟对象作为起始节点集,从这些节点根据引用关系向下搜索,搜索过程走过的路径称为引用链,如果某个对象到GC Root之间没有任何引用链,或者说是不可达的时,证明此对象不再被使用。
3.1.3、引用
JDK1.2之后,将引用分为了 强引用,软引用,弱引用,虚引用。
- 强引用 是最传统的引用定义,指的是程序代码中普遍的引用赋值。无论何种情况,只要强引用存在,垃圾收集器永远不会回收掉被引用的对象。
- 软引用 用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出outofMemeryError时,会把这些对象列进垃圾回收范围之中,进行二次回收。若这次回收还不行,抛异常。提供了SoftReference类实现软引用。
- 弱引用 也是用来描述哪些非必须的对象,但是比软引用更弱。被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论内存是否足够,都会回收弱引用关联的对象。
- 虚引用 是最弱的引用关系。一个对象是否有虚引用,完全不会对其生存时间构成影响。也无法通过虚引用获得对象实例。唯一目的是在垃圾收集器回收这个对象时,收到个系统通知。
3.1.4、对象的死刑
即使可达性分析算法中判定为不可达的对象,也不一定必须死,这时处于缓刑。
真正死亡,经历两次标记过程。
- 如果对象在进行可达性分析之后,发现没有与GC Roots相连接的引用链,那么会被第一次标记。
- 随后进行一次筛选,条件是对象是否覆盖了finalize方法,或者finalize方法已被虚拟机调用过。
- 虚拟机将这两种情况都标记为没有必要执行。
若被判定为有必要执行finalize方法,那么对象就会被放置在一个名为F-Queue的队列中,并在之后由一条虚拟机自动建立的,地调度优先级的Finalizer线程执行他们的方法。注意,这个线程并不会挨个等待执行完成,只会去执行。
finalize() 是对象逃脱死亡的最后一次机会,对象重新拯救自己的方法就是只要重新与引用链上的任一个对象建立关联即可。若这时候还没有逃脱,那么等待他的只有DEAD
注意,finalize()方法只会被虚拟机调用一次。
不要用这种手段,运行代价高昂,不确定性大,官方不推荐使用了已经。
3.1.5、回收方法区
方法区中是可以没有垃圾收集的。
方法区的垃圾收集性价比是比较低的,在Java堆中,尤其是新生代,进行一次垃圾回收可以回收70%-90%的内存空间,相比之下方法区回收具有苛刻的判定条件。回收成果远低于此。
方法区垃圾回收主要是两部分内容:废弃的常量 + 不再使用的类型
回收过程和lava堆中的非常相似。
在大量使用反射,动态代理,等字节码框架的应用场景,通常需要虚拟机具备类卸载的能力,以避免给方法区造成过大的内存压力。
3.2、垃圾收集算法
分代收集理论
从如何判定对象消亡的角度上来看,垃圾收集算法可划分为:引用计数式垃圾收集,追踪式垃圾收集,
也被称为直接垃圾收集和间接垃圾收集。
3.2.1、分代收集理论
建立在两个假说之上:
- 弱分代假说:绝大多数对象都是朝生夕灭的。
- 强分代假说:熬过越多次垃圾收集的对象就越难以消亡。
- 跨代引用假说:跨代引用相对于同代引用是极少数。
设计原则
收集器应该将Java堆划分出不同的区域,然后将回收对象根据其年龄分配到不同区域存储。
若 一个区域包含大量朝生夕灭的对象,就将他们集中放一起,每次回收只关注如何保留少量的存活而不去标记要死亡的对象,提高效率。
剩下的都是难以消亡的对象,将他们集中放一块,虚拟机就可使用较低的频率来回收这个区域
现代商用Java虚拟机,设计者一般会把Java堆划分为 新生代,老年代。
3.2.2、标记-清除算法
标记所有需要回收的对象,统一回收所有标记对象,或标记存活对象,回收未标记对象。
缺点:
- 执行效率不稳定,对象多了效率就低了。
- 内存空间碎片化问题,清除后会产生大量不连续的碎片空间,会影响需要分配较大空间,就会被迫提前调用GC。
3.2.3、标记-复制算法
为了解决标记-清除算法面对大量可回收对象时效率低的问题。
半区复制的垃圾收集算法
- 将可利用的内存空间划分为大小相等的两块,每次仅使用一块,这一块用完了,就将存活的对象复制到另一块上,然后再把已使用过的内存空间一并清除,且分配时不再考虑空间碎片化问题了。
- 代价:不用一半,太浪费了!
现在的商用Java虚拟机都是采用这种收集算法回收新生代。
Appel式回收
具体做法是 把新生代分为一块较大的Eden空间和一块较小的Survivor空间,每次分配对象只使用Eden和其中一块Servivor空间,发生垃圾收集时,将Eden和Survivor中还存活的对象一次性复制到另一块sSurvivor中,然后直接清理掉那两块内存。Eden和Survivor的比例是8:1。
逃生门
当那块Servivor存不下对象时,将存不下的对象根据分配担保机制,直接存入老年代。
3.2.4、标记-整理算法
应用于老年代
标记过程与标记清除一样,后续,是让所有存活对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
标记清除与标记整理算法本质区别是:前者是非移动算法,后者是移动式的。
3.3、经典的垃圾收集器
7种作用于不同分代的垃圾收集器。
3.3.1、Serial 收集器
这是最基础,历史最悠久的收集器,jdk1.3之前是Hotspot的唯一选择。新生代收集器。
它是单线程的,它只会使用一个处理器或一条收集线程去完成垃圾收集工作。并且,在它进行垃圾收集的时候,必须暂停其他所有工作线程,直到它运行结束。Stop The World。
优点:
简单,高效,他是所有收集器里额外内存消耗最小的。
在用户桌面场景,以及微服务应用中,分配给虚拟机管理的内存一般不会很大,所以,Serial收集器对于运行在客户端模式下的虚拟机来说是个很好的选择。
3.3.2、ParNew收集器
实际上是Serial收集器的多线程并行版本。新生代收集器。
唯一区别就是使用多线程进行垃圾收集。
它是不少运行在服务端模式下的HotSpot虚拟机,尤其是JDK7之前遗留的系统中首选的垃圾收集器。
原因:除了Serial,目前只有它可以与CMS配合工作。Parnew收集器是CMS默认收集器。
Parallel Scavenge 面向高吞吐量,CMS面向低延迟。前者和G1收集器都没有用HotSpot的垃圾收集器分代框架,而是独立起来。Serial 和 ParNew则共用了这部分代码。
JDK9之后,这个收集器被合并入CMS中,称为CMS专门处理新生代的一部分。第一款退出历史舞台的收集器。
3.3.3、Parallel Scanvenge 收集器
吞吐量优先收集器。
也是一款新生代收集器,同样是基于标记复制算法实现。也是能够并行收集的多线程收集器。
Parallel Scanvenge 收集器的特点是,他的关注点与其他收集器不同。CMS等收集器关注点在于尽可能减少垃圾收集时用户线程的停顿时间。而Parallel Scanvage的关注点是达到一个可控制的吞吐量。
区别于ParNew收集器的一个重要特性是:自适应调节策略。
3.3.4、Serial Old 收集器
是Serial的老年代版本,也是一个单线程收集器,采用标记整理算法。主要意义也是客户端模式下的HotSpot虚拟机使用。
3.3.5、Parallel Old 收集器
吞吐量优先的多线程并发收集,基于标记整理算法实现的垃圾收集器。
适用场景:注重吞吐量,或处理器资源比较稀缺的场合。
3.3.6、 CMS 收集器
Concurrent Mark Sweep 标记清除收集器。
是一种以获取最短回收停顿时间为目标的GC。
集中在互联网网站,以及基于浏览器B/S架构的应用关注服务响应速度,所以CMS收集器满足这些要求。
基于标记-清除算法实现。
运行步骤分为4步:
- 初始标记
- 并发标记
- 重新标记
- 并发清除
1、初始标记
Stop The World,仅仅记录一下GC Roots 能直接关联到的对象,速度很快。
2、并发标记
从GC Roots 的直接关联对象开始遍历整个对象图的过程。耗时长,但是可以与用户线程并发进行。会影响到吞吐量。
3、重新标记
修正并发标记期间因用户程序继续运作导致标记发生变化的那部分对象的标记记录。停顿时间长于初始标记,短于并发标记。
4、并发清除
整个过程耗时最长的并发标记和并发清除工作,都可以与用户进程并发进行。
CMS是一款优秀的垃圾收集器,并发收集,低停顿。
它是HotSpot虚拟机追求低停顿的第一次成功的尝试,但是它有三个明显的缺点。
缺点
- CMS收集器对处理器资源非常敏感。在并发阶段,虽然不会造成用户线程停顿,但是会因为占用部分线程导致应用程序变慢,占用了部分算力导致总吞吐量下降。虽然处理器核心数量越多,影响越小,但是在核心数不足四核时候,可能会因为垃圾收集线程导致用户程序执行速度大幅下降。
- CMS收集器无法处理浮动垃圾。由于用户线程在标记后还在运行,所以就会一直有新的垃圾产生。这就叫做浮动垃圾。
- 基于标记-清除算法实现,会出现大量碎片内存空间。
3.3.7、Garbage First 收集器
G1 收集器,是一款主要面向服务端的垃圾收集器。
G1 收集器是混合收集模式。(Mixed GC),面向整个Java堆,而不拘泥于新生代,老年代。
可以由用户指定停顿时间,这是一个非常强大的功能。可以让我们在不同场景下对高吞吐量和低延迟两者中权衡。
延迟高 -> 吞吐量提高 延迟低->吞吐量降低
混合收集原理:
虽然G1仍是遵循分代收集理论设计的,但是它不再坚持固定大小,固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等独立的区域(Region)。
每个区域都可根据需要,扮演新生代的Eden空间,Survivor空间,及老年代空间。
大小超过region一半的对象是大对象,存入连续的Humongous Region中。
G1 能够建立可预测的停顿时间模型,是因为它将Region作为内存回收的最小单元,每次回收都是Region的整数倍。有效避免在Java堆中进行全区域的收集。让G1跟踪各个region中的垃圾堆积价值,根据价值后台维护一个优先级列表,在用户允许的停顿时间内,优先回收那些价值最大的垃圾。故Garbage First的由来。
使用Region进行内存划分,并且具有优先级的区域回收方式,使得G1在有限时间内尽可能高的收集效率。
每个 Region 有两个 TAMS 指针,把Region中的一部分空间划分出来用于并发回收中的新对象分配。并发回收的对象的地址必须都在这两个指针上。G1默认认为这两个指针上的对象是存活的,不纳入回收范围。若回收速度赶不上分配速度,G1就会被迫冻结用户线程,进行Full GC,长时间的Stop The World
G1收集器运作的步骤
- 初始标记
- 仅仅标记一下GC Roots 能直接关联到的对象,并修改 TAMS 指针的值,让下一段用户进程并发运行时,能正确的在可用的Region上创建对象。这是借助新生代垃圾收集(Minor GC)同时进行的。暂停
- 并发标记
- 从GC root 开始堆堆中对象进行可达性分析,递归描述整个堆图,找出要回收的对象。耗时长,但可以与用户程序并发执行。当对象扫描完成之后,还要重新处理SATB记录下的在并发过程中有引用变动的对象。
- 最终标记
- 对用户线程进行短暂暂停,处理并发阶段结束的少量STAB记录。
- 筛选回收
- 负责更新Region的统计数据。对各个Region的回收价值和成本进行排序。根据用户期望的停顿时间进行回收计划的制定。
- 可自由选择多个Region进行回收。然后将回收的那部分里的存活对象转移到空Region中。再清理掉旧Region的全部空间。暂停用户线程。
G1收集器除了并发标记外,其余都要完全暂停用户线程。
所以,G1收集器的目标是,在延迟可控的情况下,尽可能提高吞吐量。
G1 与 CMS 对比
- G1 收集器可以指定停顿时间,分Region的内存布局,按收益动态确定会收集。
- G1 整体上是基于标记-整理法进行实现,局部Region 是基于标记复制法实现,两者的主要区别是G1 没有碎片化内存空间。
- G1 垃圾收集产生的内存占用,以及运行时额外的执行负载,都比CMS高。
- 一般来说,CMS在小内存应用中优势,G1在大内存应用中优势。
3.4、低延迟垃圾收集器
衡量垃圾收集器的三个重要指标是:内存占用,延迟,吞吐量。
硬件规格和性能越高,吞吐量约高。但是延迟也会越高。回收1TB肯定比回收1GB时间长。
如 shenandoah收集器 ZGC收集器…未完
3.5、内存分配与回收策略
以Serial 和 Serial Old 为例。
1、对象优先在Eden分配
当Eden没有足够空间进行分配时,发起一次Minor GC (新生代垃圾收集)。若在垃圾收集期间,虚拟机发现Survivor中的空间不够,就触发担保机制,直接将对象放入老年代。
2、大对象直接进入老年代
大对象,对虚拟机内存分配来讲,是个坏消息。比这个还坏的消息就是遇到一堆朝生夕灭的短命大对象。写程序要避免。
原因:
- 在分配空间时,容易导致明明有好多空间,但是要提前出发GC,来获取足够空间存放他们。
- 在复制对象时,需要更高额的额外内存复制开销。这也就说明了,Java虚拟机直接将大对象存入老年代,防止在Eden 和 Survivor之间来回复制。
3、长期存活对象将进入老年代
决策哪些对象放入新生代,哪些放入老年代。
过程:
- 对象通常在Eden里诞生,若它能熬过第一次Minor GC,它将会被放入Survivor中,存储在对象头的对象年龄计数器+1,之后若再熬过GC,再加一,直到增加到一定程度,默认是15,就会被晋升到老年代。
4、动态对象年龄判定
为了能适应不同程序内存状况,HotSpot虚拟机并不是永远要求对象年龄达到阈值才晋升老年代。若在Survivor中年龄相同的对象总和达到了Survivor的一半,年龄大于或等于该年龄的对象会被晋升。
5、空间分配担保
在发生 Minor GC 之前,虚拟机必须先检查老年代空间最大可用连续空间是否大于新生代所有对象总空间,大于这次GC是安全的,否则虚拟机会先查看是否允许担保失败,若允许,虚拟机会查看老年代最大可用空间是否大于历次晋升老年代晋升的平均大小,若大于,则尝试依次Minor GC 尽管是有风险的。若小于,或者是不允许的,则进行依次Full GC。
注意,JDK6以后,规则改为,只要老年连续空间大于新生代对象总大小,或者大于历次晋升的平均大小,就Minor GC 否则 Full GC。