JVM内存模型的概述
- Java语⾔为什么优势巨⼤,因为⼀处编译随处运⾏
- 在内存管理机制之下,不再需要为每⼀个new操作去写配对的内存分配和回收等代码, 不容易出现内存泄漏和内存溢出等问题
JVM运⾏时数据区分布图讲解
- 线程共享数据区:⽅法区、堆
- 线程隔离数据区:虚拟机栈、本地⽅法栈、程序计数器
JVM内存模型之程序计数器
是什么?
- 程序计数器是⼀块较⼩的内存空间,它可以看作是当前线程所执⾏的字节码的⾏号指示器
- 线程是⼀个独⽴的执⾏单元,是由CPU控制执⾏的
- 字节码解释器⼯作时就是通过改变这个计数器的值来选取下⼀条需要执⾏的字节码指令,分⽀、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
- 为了线程切换后能恢复到正确的执⾏位置,每条线程都需要有⼀个独⽴的程序计数器,各条线程之间计数器互不影响,独⽴存储,我们称这类内存区域为“线程私有”的内存
特点?
- 内存区域中唯⼀⼀ 个没有规定任何 OutOfMemoryError 情况的区域(OutOfMemoryError表示堆内存溢出)
JVM内存模型之Java虚拟机栈讲解
是什么?
- ⽤于作⽤于⽅法执⾏的⼀块Java内存区域
- 每个⽅法在执⾏的同时都会创建⼀个栈帧(Stack Framel)⽤于存储局部变量表、操作数栈、动态链接、⽅法出⼝等信息。每⼀个⽅法从调⽤直⾄执⾏完成的过程,就对应着⼀个栈帧在虚拟机栈中⼊栈到出栈的过程
特点?
- 局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)以及对象引⽤(reference 类型)
- 如果线程请求的栈深度⼤于虚拟机所允许的深度,将抛出 StackOverflowError 异常(StackOverflowError 栈内存溢出)
发生StackOverflowError 栈内存溢出demo:
public class A {
public static void a(){
System.out.println("enter method a");
}
public static void b(){
b();
System.out.println("enter method b");
}
public static void main(String[] args) {
b();
System.out.println("enter method main");
}
}
JVM内存模型之本地⽅法栈讲解
是什么?
- ⽤于作⽤于本地⽅法执⾏的⼀块Java内存区域
本地方法:无方法体且使用native的方法
public native static void a();
- 与Java虚拟机栈相同,每个⽅法在执⾏的同时都会创建⼀个栈帧(Stack Framel)⽤于存储局部变量表、操作数栈、动态链接、⽅法出⼝等信息。每⼀个⽅法从调⽤直⾄执⾏完成的过程,就对应着⼀个栈帧在虚拟机栈中⼊栈到出栈的过程
特点?
- Hotshot将Java虚拟机栈和本地⽅法栈合⼆为⼀
JVM内存模型之Java堆
是什么?
- 是Java内存区域中⼀块⽤来存放对象实例的区域,【⼏乎所有的对象实例都在这⾥分配内存】
- 此内存区域的唯⼀⽬的就是存放对象实例
- Java 堆(Java Heap)是 Java 虚拟机所管理的内存中最⼤的⼀块 Java 堆,是被所有线程共享的⼀块内存区域
特点?
- Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC 堆”(Garbage)
- Java堆可以分成新⽣代和⽼年代,新⽣代可分为To Space、From Space、Eden
JVM内存模型之⽅法区
是什么?
- 是各个线程共享的内存区域,它⽤于存储已被虚拟机加载的类信息、常量、静态变量、即编译器编译后的代码等数据
- 什么是类信息:类版本号、⽅法、接⼝
- 内存中存放类信息、静态变量等数据,属于线程共享的⼀块区域
特点?
- 并⾮数据进⼊了⽅法区就如永久代的名字⼀样“永久”存在了。这个区域的内存回收⽬标主要是针对常量池的回收和对类型的卸载
- ⽅法区也会抛出OutofMemoryError,当它⽆法满⾜内存分配需求时
JVM内存模型之⽅法区运⾏时常量池
是什么?
- 运⾏时常量池是⽅法区的⼀部分,class⽂件除了有类的版本、字段、⽅法、接⼝等描述信息外,还有⼀项信息是常量池,⽤于存放编译器⽣成的各种字⾯量和符号引⽤,这部分内容将在类加载后进⼊⽅法区的运⾏时常量池中存放
- 此内存区域的唯⼀⽬的就是存放对象实例
- Java 堆(Java Heap)是 Java 虚拟机所管理的内存中最⼤的⼀块 Java 堆,是被所有线程共享的⼀块内存区域
特点?
- 运⾏时常量池是⽅法区的⼀部分,受到⽅法区内存的限制,当常量池在申请到内存时会抛出OutOfMemoryError异常
Java程序员不可不知的对象创建底层步骤细节
对象创建的流程步骤包括哪些:
-
虚拟机遇到⼀条new指令时,⾸先检查这个对应的类能否在常量池中定位到⼀个类的符号引⽤
-
判断这个类是否已被加载、解析和初始化
-
为这个新⽣对象在Java堆中分配内存空间,其中Java堆分配内存空间的⽅式主要有以下两种:
1.指针碰撞
- 分配内存空间包括开辟⼀块内存和移动指针两个步骤
- ⾮原⼦步骤可能出现并发问题,Java虚拟机采⽤CAS配上失败重试的⽅式保证更新操作的原⼦性
2.空闲列表
- 分配内存空间包括开辟⼀块内存和修改空闲列表两个步骤
- ⾮原⼦步骤可能出现并发问题,Java虚拟机采⽤CAS配上失败重试的⽅式保证更新操作的原⼦性
-
将分配到的内存空间都初始化为零值
-
设置对象头相关数据
-
执⾏对象⽅法
Java程序员不可不知的对象结构
- 对象头Header
对象头⽤于存储对象的元数据信息,Mark Word 部分数据的⻓度在32位和64位虚拟机(未开启压缩指针)中分别为32bit和64bit,存储对象⾃身的运⾏时数据如哈希值等。Mark Word⼀般被设计为⾮固定的数据结构,以便存储更多的数据信息和复⽤⾃⼰的存储空间。 - 实例数据Instance Data
类型指针指向它的类元数据的指针,⽤于判断对象属于哪个类的实例。
实例数据存储的是真正有效数据,如各种字段内容,各字段的分配策略为longs/doubles、ints、shorts/chars、bytes/boolean、oops(ordinary object pointers),相同宽度的字段总是被分配到⼀起,便于之后取数据。⽗类定义的变量会出现在⼦类定义的变量的前⾯。 - 对其填充Padding
对⻬填充部分仅仅起到占位符的作⽤
Java程序员不可不知的对象访问定位⽅式
当我们在堆上创建⼀个对象实例后,就要通过虚拟机栈中的reference类型数据来操作堆上的对象。现在主流的访问⽅式有两种(HotSpot虚拟机采⽤的是第⼆种):
- 使⽤句柄访问对象。即reference中存储的是对象句柄的地址,⽽句柄中包含了对象实例数据与类型数据的具体地址信息,相当于⼆级指针。
- 直接指针访问对象。即reference中存储的就是对象地址,相当于⼀级指针。
对⽐
垃圾回收分析:⽅式一垃圾回收移动对象时,reference中存储的地址是稳定的地址,不需要修改,仅需要修改对象句柄的地址;⽅式二垃圾回收时需要修改reference中存储的地址。
访问效率分析:⽅式⼆优于⽅式⼀,因为⽅式⼆只进⾏了⼀次指针定位,节省了时间开销,⽽这也是HotSpot采⽤的实现⽅式。
GC垃圾回收
为什么要垃圾回收?
答:Java语⾔中⼀个显著的特点就是引⼊了垃圾回收机制,使c++程序员最头疼的内存管理的问题迎刃⽽解。由于有个垃圾回收机制,Java中的对象不再有“作⽤域”的概念,只有对象的引⽤才有“作⽤域”。垃圾回收可以有效的防⽌内存泄露,有效的使⽤空闲的内存。
对象是否存活判断:堆中每个对象实例都有⼀个引⽤计数。当⼀个对象被创建时,且将该对象实例分配给⼀个变量,该变量计数设置为1。当任何其它变量被赋值为这个对象的引⽤时,计数加1(a = b,则b引⽤的对象实例的计数器+1),但当⼀个对象实例的某个引⽤超过了⽣命周期或者被设置为⼀个新值时,对象实例的引⽤计数器减1。任何引⽤计数器为0的对象实例可以被当作垃圾收集。当⼀个对象实例被垃圾收集时,它引⽤的任何对象实例的引⽤计数器减1。
对象存活算法引⽤计数法
引用计数法的逻辑非常简单,但是存在问题,Java并不采用这种方式进行对象存活判断。
引用计数法的逻辑是:在堆中存储对象时,在对象头处维护一个counter计数器,如果一个对象增加了一个引用与之相连,则将counter++。如果一个引用关系失效则counter–。如果一个对象的counter变为0,则说明该对象已经被废弃,不处于存活状态。
优缺点:
- 引⽤计数收集器可以很快的执⾏,交织在程序运⾏中。对程序需要不被⻓时间打断的实时环
境⽐较有利。 - ⽆法检测出循环引⽤。如⽗对象有⼀个对⼦对象的引⽤,⼦对象反过来引⽤⽗对象。这样,他们的引⽤计数永远不可能为0。
对象存活算法可达性分析
可达性分析算法的概念(⼜叫根搜索法):根搜索算法是从离散数学中的图论引⼊的,程序把所有的引⽤关系看作⼀张图,从⼀个节点GC ROOT开始,寻找对应的引⽤节点,找到这个节点以后,继续寻找这个节点的引⽤节点,当所有的引⽤节点寻找完毕之后,剩余的节点则被认为是没有被引⽤到的节点,即⽆⽤的节点。
Java中可作为GC Root的对象有:
- 虚拟机栈中引⽤的对象(本地变量表)
- 本地⽅法栈中引⽤的对象
- ⽅法区中静态属性引⽤的对象
- ⽅法区中常量引⽤的对象
剖析标记清除算法
最基础的收集算法是“标记-清除”(Mark-Sweep)算法,如同它的名字⼀样,算法分为“标记”和“清除”两个阶段:
- ⾸先标记出所有需要回收的对象,在标记完成后统⼀回收所有被标记的对象。
- 它的主要不⾜有两个:
1. ⼀个是效率问题,标记和清除两个过程的效率都不⾼;
2. 另⼀个是空间问题,标记清除之后会产⽣⼤量不连续的内存碎⽚,空间碎⽚太多可能会导致以后在程序运⾏过程中需要分配较⼤对象时,⽆法找到⾜够的连续内存⽽不得不提前触发另⼀次垃圾收集动作。
标记复制算法
为什么出现复制算法?
- 为了解决效率问题,⼀种称为“复制”(Copying)的收集算法出现了,它将可⽤内存按量划分
为⼤⼩相等的两块,每次只使⽤其中的⼀块 - 当这⼀块的内存⽤完了,就将还存活着的对象复制到另外⼀块上⾯,然后再把已使⽤过的内
存空间⼀次清理掉。这样使得每次都是对整个半区进⾏内存回收,内存分配时也就不⽤考虑内存碎⽚等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运⾏⾼效
现在的商业虚拟机都采⽤这种收集算法来回收新⽣代,研究表明,新⽣代中的对象 98%是“朝⽣夕死”的,所以并不需要按照 1:1 的⽐例来划分内存空间,⽽是将内存分为⼀块较⼤的 Eden 空间和两块较⼩的 Survivor 空间(Survivor from 、Survivor to),每次使⽤ Eden 和其中⼀块 Survivor。 Eden、Survivor from 、Survivor to,内存⽐例 8:1:1。
当回收时,将 Eden 和 Survivor 中还存活着的对象⼀次性地复制到另外⼀块 Survivor 空间上,最后清理掉 Eden 和刚才⽤过的 Survivor 空间。HotSpot 虚拟机默认 Eden 和 Survivor 的⼤⼩⽐例是 8:1, 也就是每次新⽣代中可⽤内存空间为整个新⽣代容量的 90% (80%+10%),只有 10% 的内存会被“浪费”。当然,98%的对象可回收只是⼀般场景下的数据,我们没有办法保证每次回收都只有不多于 10%的对象存活,当 Survivor 空间不够⽤时,需要依赖其他内存(这⾥指⽼年代)进⾏分配担保(Handle Promotion)。
标记整理算法
标记整理算法解决了什么问题:复制收集算法在对象存活率较⾼时就要进⾏较多的复制操作,效率将会变低。更关键的是,如果不想浪费 50%的空间,就需要有额外的空间进⾏分配担保,以应对被使⽤的内存中所有对象都 100%存活的极端情况,所以在⽼年代⼀般不能直接选⽤这种算法。
根据⽼年代的特点,有⼈提出了另外⼀种“标记-整理(Mark- Compact)算法,标记过程仍然与“标记-清除”算法⼀样,但后续步骤不是直接对可回收对象进⾏清理,⽽是让所有存活的对象都向⼀端移动,然后直接清理掉端边界以外的内存。
分代收集算法
⼀般把 Java 堆分为新⽣代和⽼年代,这样就可以根据各个年代的特点采⽤最适当的收集算法。在新⽣代中,每次垃圾收集时都发现有⼤批对象死去,只有少量存活,那就选⽤复制算法,只需要付出少量存活对象的复制成本就可以完成收集。⽽⽼年代中因为对象存活率⾼、没有额外空间对它进⾏分配担保,就必须使⽤“标记-清理”或者“标记⼀整理”算法来进⾏回收。
Serial垃圾收集器
Serial是⼀个单线程的垃圾收集器,垃圾回收器是内存回收的具体实现。
Serial垃圾收集器的特点:
- “Stop The World”,它进⾏垃圾收集时,必须暂停其他所有的⼯作线程,直到它收集结束
- 在⽤户不可⻅的情况下把⽤户正常⼯作的线程全部停掉
- 使⽤场景:多⽤于桌⾯应⽤,Client端的垃圾回收器
- 桌⾯应⽤内存⼩,进⾏垃圾回收的时间⽐较短,只要不频繁发⽣停顿就可以接受
Parnew垃圾收集器
ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使⽤多条线程进⾏垃圾收集之外,其余⾏为包括 Serial 收集器可⽤的所有控制参数(例如:-XX: SurvivorRatio、-XX:PretenureSize’ Threshold、-XX: HandlePromotionFailure 等)、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全⼀样,在实现上,这两种收集器也共⽤了相当多的代码。
Parnew垃圾收集器的特点
- ParNew 收集器除了多线程收集之外,其他与 Serial 收集器相⽐并没有太多创新之处,但它却是许多运⾏在 Server 模式下的虚拟机中⾸选的新⽣代收集器,其中有⼀个与性能⽆关但很重要的原因是,除了 Serial 收集器外,⽬前只有它能与 CMS 收集器配合⼯作。
- 使⽤-XX: ParallelGCThreads 参数来限制垃圾收集的线程数
- 多线程操作存在上下⽂切换的问题,所以建议将-XX: ParallelGCThreads设置成和CPU核数相同,如果设置太多的话就会产⽣上下⽂切换消耗
并发与并⾏的概念讲解
- 并⾏(Parallel):指多条垃圾收集线程并⾏⼯作,但此时⽤户线程仍然处于等待状态。
- 并发(Concurrent):指⽤户线程与垃圾收集线程同时执⾏(但不⼀定是并⾏的,可能会交替执⾏),⽤户程序在继续运⾏,⽽垃圾收集程序运⾏于另⼀个 CPU 上.
Parallel Scavenge收集器
- Parallel Scavenge 收集器是⼀个新⽣代收集器,它也是使⽤复制算法的收集器,⼜是并⾏的多线程收集器。
- 由于与吞吐量关系密切,Parallel Scavenge 收集器也经常称为“吞吐量优先”收集器。
- 吞吐量是什么?CPU⽤于运⾏⽤户代码的时间与CPU总时间的⽐值,99%时间执⾏⽤户线程,1%时间回收垃圾 ,这时候吞吐量就是99%。
特点:
- Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时⽤户线程的停顿时间,⽽ Parallel Scavenge 收集器的⽬标则是达到个可控制的吞吐(Throughput)。所谓吞吐量就是 CPU ⽤于运⾏⽤户代码的时间与 CPU 总消耗时间的⽐值,即吞吐量=运⾏⽤户代码时间/(运⾏⽤户代码时间+垃圾收集时间),虚拟机总共运⾏了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99% ,停顿时间越短就越适合需要与⽤户交互的程序,良好的响应速度能提升⽤户体验,⽽⾼吞吐量则可以⾼效率地利⽤ CPU 时间,尽快完成程序的运算任务,主要适合在后台运算⽽不需要太多交互的任务。
- 虚拟机会根据当前系统的运⾏情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最⼤的吞吐量,这种调节⽅式称为 GC⾃适应调节策略。
- XX:MaxGCPauseMillis参数:GC停顿时间。500MB ——>300MB,这个参数配置太⼩的话会发⽣频繁GC。
- -XX:GCTimeRatio参数:设置吞吐量。可设为99%。
Serial old收集器,它是⼀个单线程收集器,使⽤"标记–整理"算法。
Parallel old收集器是Parallel Scavenge收集器的⽼年代版本,使⽤多线程+标记整理算法。
CMS垃圾收集器
- CMS (Concurrent Mark Sweep)收集器是-种以获取最短回收停顿时间为⽬标的收集器。
- ⽬前很⼤⼀部分的Java应⽤集中在互联⽹站或者B/S系统的服务端上,这类应⽤尤其重 视服务的响应速度,希望系统停顿时间最短,以给⽤户带来较好的体验。
- CMS 收集器是基于“标记-清除”算法实现的
步骤流程:
- 初始标记(CMS initial mark) -----标记⼀下 GC Roots 能直接关联到的对象,速度很快
- 并发标记(CMS concurrent mark --------并发标记阶段就是进⾏ GC RootsTracing 的过程
- 重新标记(CMS remark) -----------为了修正并发标记期间因⽤户程序导致标记产⽣变动的标记记录
- 并发清除(CMS concurrent sweep)
CMS垃圾收集器的缺点:
- 对CPU资源⾮常敏感
- ⽆法处理浮动垃圾,程序在进⾏并发清除阶段⽤户线程所产⽣的新垃圾
- 标记-清除暂时空间碎⽚
G1垃圾收集器
G1是⼀款⾯向服务端应⽤的垃圾收集器。
特点:G1 中每个 Region 都有⼀个与之对应的 Remembered Set,当进⾏内存回收时,在 GC 根节点的枚举范围中加⼊ Remembered Set 即可保证不对全堆扫描也不会有遗漏 检查Reference引⽤的对象是否处于不同的Region。
G1 收集器的运作⼤致可划分为以下⼏个步骤:
- 初始标记(Initial Marking) --标记⼀下 GC Roots 能直接关联到的对象
- 并发标记(Concurrent Marking)—从GC Root 开始对堆中对象进⾏可达性分析,找出存活的对象,这阶段耗时较⻓,但可与⽤户程序并发执⾏
- 最终标记(Final Marking) —为了修正在并发标记期间因⽤户程序继续运作⽽导致标记产⽣变动的那⼀部分标记记录。虚拟机将这段时间对象变化记录在线程 Remembered Set Logs⾥⾯,最终标记阶段需要把 Remembered Set Logs的数据合并到 Remembered Set 中
- 筛选回收(Live Data Counting and Evacuation)
G1的优势有哪些:
- 空间整合:基于“标记⼀整理”算法实现为主和Region之间采⽤复制算法实现的垃圾收集
- 可预测的停顿:这是 G1 相对于 CMS 的另⼀⼤优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建⽴可预测的停顿时间模型
- 在 G1 之前的其他收集器进⾏收集的范围都是整个新⽣代或者⽼年代,⽽ G1 不再是这样。使⽤ G1 收集器时,Java 堆的内存布局就与其他收集器有很⼤差别,它将整个 Java 雄划分为多个⼤⼩相等的独⽴区域(Region),虽然还保留有新⽣代和⽼年代的概念,但新⽣代和⽼年代不再是物理隔髙的了,它们都是⼀部分 Region(不需要连续)的集合。
- G1 收集器之所以能建⽴可预测的停顿时间模型,是因为它可以有计划地避免在整个 Java 堆中进⾏全区域的垃圾收集。G1 跟踪各个 Regions ⾥⾯的垃圾堆积的价值⼤⼩(回收所获得的空间⼤⼩以及回收所需时间的经验值),在后台维护⼀个优先列表,每次根据允许的收集时间,优先回收价值最⼤的 Region(这也就是 Garbage- Firsti 名称的来由)。这种使⽤Region 划分内存空间以及有优先级的区域回收⽅式,保证了 G1 收集器在有限的时间内可以获取尽可能⾼。
堆内存分配⽅式,分配规则
Java堆内存区域的划分图:
对象分配的规则:
- 对象主要分配在新⽣代的 Eden 区上
- 如果启动了本地线程分配缓冲,将按线程优先在 TLAB 上分配
- 少数情况下也可能会直接分配在⽼年代中
GC参数指定垃圾回收
- -Xms20 M、-Xmx20 M、-Xmn1 0 M 这 3 个参数限制了 Java 堆⼤⼩为 20 MB,不可扩展,其中 10 MB 分配给新⽣代,剩下的 10 MB 分配给⽼年代。-Xx: SurvivorRatio= 8 决定了新⽣代中 Eden 区与两个 Survivor 区的空间⽐例是 8:1:1
新⽣代与⽼年代
- 新⽣代 GC (Minor GC):指发⽣在新⽣代的垃圾收集动作,因为 Java 对象⼤多都具备朝⽣夕灭的特性,所以 Minor GC ⾮常频繁.
- ⽼年代 GC (Major GC/ Full GC):指发⽣在⽼年代的 GC,出现了 Major GC,经常会伴随⾄少⼀次的 Minor GC(但⾮绝对的,在 Parallel Scavenge 收集器的收集策略⾥就有直接进⾏ Major GC 的策略选择过程)。Major GC 的速度⼀般会⽐ Minor GC 慢 10 倍以上。
⼤对象分配原则
- 所谓的⼤对象是指,需要⼤量连续内存空间的 Java 对象,最典型的⼤对象就是那种很⻓的字符串以及数组。
- 虚拟机提供了⼀个-XX: PretenureSizeThreshold 参数,令⼤于这个设置值的对象直接在⽼年代分配。这样做的⽬的是避免在 Eden 区及两个 Survivor 区之间发⽣⼤量的内存复制。
代码演练⼤对象配置
- -verbose:gc -XX:+PrintGCDetails 开启GC⽇志打印
- -Xms20 M 设置JVM初始内存为20M
- -Xmx20 M 设置JVM最⼤内存为20M
- -Xmn10 M 设置年轻代内存⼤⼩为10M
-verbose:gc -XX:+PrintGCDetails -XX:+UseSerialGC -Xms20M -Xmx20M -Xmn10M -XX:PretenureSizeThreshold=3145728
逃逸分析和栈上分配
逃逸分析:逃逸分析的基本⾏为就是分析对象动态作⽤域,当⼀个对象在⽅法中被定义后,它可能被外部⽅法所引⽤,称为⽅法逃逸。甚⾄还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
栈上分配:栈上分配就是把⽅法中的变量和对象分配到栈上,⽅法执⾏完后⾃动销毁,⽽不需要垃圾回收的介⼊,从⽽提⾼系统性能。
-XX:+DoEscapeAnalysis开启逃逸分析(jdk1.8默认开启,其它版本未测试)
-XX:-DoEscapeAnalysis 关闭逃逸分析