java 运行时数据区域:
- 程序计数器 => (每个线程都包含一个程序计数器)用来记录字节码执行的行号,字节码指令的循环,跳转,异常处理,线程恢复等需要依靠计数器。
- Java虚拟机栈 => 主要用来描述Java方法执行的内存模型,(每个线程都包含一个虚拟机栈)主要用来处理方法的调用,虚拟机栈中的存储单元是栈帧,方法在执行的同时都会在虚拟机栈中创建一个栈帧,栈帧包含操作数栈,局部变量表,动态链接和方法出口等,每个方法从调用到执行完毕都对应着一个栈帧在虚拟机栈中的入栈和出栈的过程。局部变量表的存储单位为slot(4个字节),因此double 和long类型需要占用2个slot的存储空间。
栈的深度有一定限制,当深度操作最大的调用栈大小会出现StackOverflowError异常。 - 本地方法栈 => 为虚拟机执行java方法的服务(native方法),类似虚拟机栈
- java堆 => 对象和数组存储的场所,也是gc收集器的主要管理区域,现在收集器基本采用分代收集算法,可通过-Xmx -Xms来分配堆内存大小,当堆内存无法分配内存时,会出现OutOfMemoryError异常。
- 方法区 => 用于存储已被虚拟机加载的类信息,静态变量,常量以及即时编译器编译后的代码等,方法区无法分配内存是会出现OutOfMemoryError异常。
- 运行时常量池 =>方法区的一部分,class文件除了类的版本信息,字段,方法,接口等信息外,还包含着常量池,用于存放编译器生成的各种字面量和符号引用。这部分信息在类加载进方法去后存储于运行时常量池, String.intern()在运行时可以加入新的字符串常量到此空间内,当常量池无法申请内存时会出现OutOfMemoryError异常。
7.直接内存 => 也称为堆外内存,计算机本身的机身内存,非虚拟机运行时数据区的一部分,jdk中的NIO引入了基于管道和缓冲区的I/O方式。使用native函数库直接分配堆外内存,然后通过一个存储在堆中的DirectByteBuffer对象作为这块内存的引用进行操作,提高了性能,避免了java堆和native堆中来回复制数据。但是由于这块内存不由虚拟机来管控,所以需要手动对其进行释放。
对象的创建分为3个步骤:
- 为其分配内存空间;
- 初始化对象;
- 引用指针指向对象所分配的内存地址;
但是在实际场景中会存在指令重排的情况,一般在单例模式中会使用volatile来避免指令重排
对象的创建过程:
- new => 触发虚拟器检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个
符号引用代表的类是否已被加载,解析和初始化过,如果没有则首先进行类加载的过程。
如果类加载检查通过(也就是类已被加载进方法区),接下来虚拟机为对象分配内存,内存大小在类加载
完成后就已经确定,堆为对象分配内存的方式分为2种:
- 指针碰撞:这里需要保证内存是绝对规整的,所用用过的内存在一边,空闲的在另外一边,中间会采用1个指针
作为分界点的指示器,需要分配内存时,就偏移所需内存大小的便宜了即可。 - 空闲列表:堆内存不是规整的,维护一个列表,用来记录哪些内存块可用。
内存的分配方式受到垃圾收集器类别的影响,Serial,ParNew等带Compact(紧凑)过程的收集器时,系统采用指针碰撞来分配内存;
CMS这种基于Mark-Sweep算法的收集器,采用空闲列表来分配对象内存。
对象创建过程有2种 - 采用CAS同步的方式来完成,CAS是cpu硬件级别的原子操作,防止并发造成的线程安全问题。
- 内存分配动作按照线程划分在不同的空间进行,意思就是每个线程在java堆中预先分配一小块内存,
称为本地线程分配缓冲(thread local allocation buffer TLAB),哪个线程需要分配内存,就在TLAB上分配。
当TLAB分配完后才会采用CAS去分配。
是否开启TLAB可通过 -XX:+/-UseTLAB
内存分配完成后,初始化内存中的参数(基本类型为0,引用类型为null);
接下来虚拟机要对对象进行必要设置,比如说这个对象属于哪个类的实例,如果找到对应类的元数据信息,对象的
hash码,对象的GC分代年龄,这些信息都存储于对象头(Object Header)中。
必要设置完成后,就虚拟机而言,对象创建成功了,但就java而言,还没完成,还需要执行方法,一般来说
由字节码中是否跟随invokespecial指令来决定,执行完方法后,对象才算完全产生出来。
对象的内存布局:
对象的内存布局分为:对象头,实例数据和对齐填充
对象头分为:mark word 和类型指针,mark word主要存储对象的hash码,GC分代年龄,锁状态标志,线程持有的锁,
偏向线程ID, 偏向时间戳。锁状态标志占2个bit, 01 => 未锁定, 00 => 轻量级锁定 10 => 重量级锁定 11 => gc标记
01 => 偏向锁;如果是数组对象,则还有一块大小是用来记录数组长度的数据。
类型指针主要执行所属类的元数据空间,虚拟机通过这个指针来确定对象所属类的实例。
实例数据:主要用来存储真正的字段内容(包含父类)。
对齐填充:jvm强制要求对象的起始地址必须是8个字节的整数倍。如果当前没达到则来填充
对象的访问定位:
操作数栈上的reference数据来操作堆上的具体对象。对象的访问主要分为2种:
1.句柄:操作数栈上的reference存储的是对象的句柄地址,句柄包含对象实例数据(堆上)
与类型数据(方法区上)各自的具体地址信息。(优势:对象被移动不会修改reference的地址,只会修改句柄地址;
劣势:如果对象访问频繁,寻址的时间开销较大)
2. 直接指针:reference存储的直接是对象地址。优劣势和句柄相反。
垃圾收集器
- gc判断哪些对象已死:
- 引用计数算法:每个对象都包含一个引用计数器,用地方引用他时,就+1,引用失效就 -1.为0是就是不可再用,会被gc回收。优势:实现简单高效,缺陷:无法解决对象间相互引用的问题。
java虚拟机并未采用此算法来管理内存。 - 可达性分析法:从"GC Roots"的对象作为起始点,从这些节点开始向下搜索,没有被GC Roots的引用链包含的话,则说明对象不可用,会被判定为可回收对象。
GC Roots对象包括:- 虚拟机栈(栈帧中的局部变量表)中引用的对象;
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;(也就是类中的final引用对象);
- 本地方法栈中JNI(java native interface)中native方法引用的对象。
- 引用计数算法:每个对象都包含一个引用计数器,用地方引用他时,就+1,引用失效就 -1.为0是就是不可再用,会被gc回收。优势:实现简单高效,缺陷:无法解决对象间相互引用的问题。
java对引用的概念在1.2之前只存在被引用和没用被引用的状态,如果希望一些对象在内存足够的时候能保留在堆中,在内存空间进行垃圾收集后仍然很紧张的情况下,才去回收这些对象,比如说很多系统的缓存功能。
jdk1.2之后扩充了引用的概念,引用分为强引用(Strong Reference),软引用(Soft Reference),弱引用(Weak Reference)和虚引用(Phantom Reference)
- 强引用:Object obj = new Object() 只要强引用存在,就不会被GC
- 软引用:用来描述一些还有用但是并非必需的对象,当系统内存在回收后依旧紧张的情况,会将这些对象列入下次回收的名单中(一般用于内存敏感的高速缓存)。SoftReference类来实现。
- 弱引用:弱引用关联的对象只能生存到下次垃圾收集发生之前,采用WeakReference类来实现弱引用。
- 虚引用:实际场景中基本不用。
可达性分析法中不可达的对象判断真正死亡需要经历2个标记步骤,第一次是没有与GC Roots相连接的引用链,然后,jvm会判断当前对象是否覆盖了finalize()方法或者是否被执行过,如果都没有则判定死亡,否则会先将对象放置到F-Queue队列中,然后交由jvm去发现执行。finalize()方法是对象最后逃脱死亡的机会。将自身this赋给类变量或者对象的成员变量。但是finalize()方法只会被执行一次,第二次自救会直接被回收。finalize()不建议使用,尽量采用try finally来替代。
方法区的回收一般回收废弃常量和无用的类。
垃圾收集算法
- 标记清除算法 劣势会产生大量不连续的内存碎片,当要分配一个大对象时,不得不再次进行gc, 而且每次gc都会产生更多的内存碎片。
- 复制算法:将可用内存按照容量划分了大小相等的2块,每次只是用其中一块,当一块的内存用完,进行gc,将存活的对象复制到另一块内存后,清空原先一块的内存, 实现简单,运行高效,但是将内存缩小为原来的一半显得有些不合理。新生代的回收基本采用这种方式来实现。因为新生代的对象的98%的生命周期都很短,都是朝生夕死,而复制算法的实现也不会对半划分,具体实现是在内存分为一块较大的Eden空间和两块较小的Survivor空间(比例为8:1:1),每次使用Eden空间和其中一块Survivor空间,gc后,会将存活的对象复制到另外一块Survivor空间中,这意味着只会有10%的空间被浪费,如果Survivor空间不足以接收存活的对象时,此时需要依赖老年代来帮忙分担。这个算法的缺点是如果存活的对象很多时,会进行较多次的复制操作,效率便会降低,但是用于新生代中这个算法是很合适的。
- 标记-整理算法:这个算法一般针对老年代而言,比较过程和标记清除算法相同,但后续的步骤不同,标记完成后,让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
- 分代收集算法:当前虚拟机都采用此算法,这个算法没什么新的思想,他将虚拟机的堆内存分为新生代和老年代,新生代采用复制算法来进行垃圾收集,老年代一般采用标记整理算法进行垃圾收集。
可达性分析中,必须在一个确保一致性的快照上进行,因为如果如果分析过程引用还在变化便无法分析出准确的结果。这点是导致GC进行时必须停顿所有java执行线程(这个过程被称为stop the world)。OopMap结构快速帮助GcRoots获取引用链的关系,当程序运行到安全点(safepoint)时,停顿下来开始执行gc.
gc发生时使所有线程停顿下来有2种方案“
- 抢占式中断:强制让所有线程中断,如果发现有现成中断的地方不是安全点上,就恢复线程,使其运行到安全点。
- 主动式中断,一般都采用这种方式。不主动对线程进行操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时,就中断挂起。
垃圾收集器
并行(Parallel):并行用于描述多个垃圾收集器线程间的关系,说明同一时间有多条这样的线程在协同工作,此时通常默认用户线程是处于等待状态 并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程和用户线程都在运行,但由于垃圾收集器线程会占用一部分系统资源,所以程序的吞吐量依然会受到一定影响。 jdk8默认采用-XX:+UseParallelGC 作为垃圾收集器 垃圾收集器分为新生代中的收集器和老年代中的收集器 新生代收集器包括:Serial 、ParNew、 Parallel Scavenge 老年代收集器包括:CMS、Serial Old(MSC) 、parallel Old 其他: G1
- Serial(序列化,顺序的)收集器:jdk1.3之前虚拟机新生代收集的唯一选择。这是一个单线程的收集器,此收集器在进行gc时,其他所有的工作线程必须停止,直到它收集结束。
Serial收集器对新生代的垃圾对象进行回收,相应的所采用的的算法便是复制算法。对应的老年代的收集器为Serial Old收集器,采用的是标记-整理算法来回收垃圾对象。
这个收集器优点在于他是采用单线程来完成垃圾回收的,避免了多线程频繁的上下文切换,因此在单线程环境下收集效率非常高。Client模式下的虚拟机采用此收集器是很好的选择。 - ParNew(新品)收集器: ParNew是Serial的多线程版本,也是作用于新生代,在gc的时候,使用多条线程进行垃圾回收,此外,还提供了包括Serial收集器可用的所有控制参数。ParNew是Server模式下的虚拟机中首选的新生代收集器,除此之外,它还可以与CMS(老年代收集器)配合使用。可使用-XX:+UseParNewGC选项来强制指定。如果当前硬件配置为单cpu,则采用Serial收集器最好。多CPU环境下,此收集器表现良好,默认的线程数和当前机器的cpu核数相同,当然也可以通过-XX:ParallelGCThreads来限制垃圾收集时的线程数。
- Parallel Scanvenge(并行清理) 收集器:是新生代的收集器,也是采用复制算法的收集器,又是并行的多线程收集器。Parallel Scanvenge收集器他关注点和CMS不同,CMS关注与尽可能地缩短垃圾收集时用户线程的停顿时间(也就是响应时间),而Parallel Scanvenge 收集器目标为达到一个可控制的吞吐量。因为与CMS关注点不同,因此无法一起使用。
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间),比如说代码时间为99,垃圾收集时间为1,则吞吐量为99%。这也是java8默认的新生代收集器。
停顿时间越短就越适合需要与用户交互的程序,良好的响应速度可以提升用户体验,
而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多的交互任务。
-XX:MaxGCPauseMillis参数用于设置最大停顿时间 Parallel Scanvenge收集器尽量保证内存回收消耗的事件不超过这个值
-XX:GCTimeRatio 参数用于设置吞吐量大小
Parallel Scanvenge因为关注的是吞吐量,因此也被称为”吞吐量优先“的收集器。
-XX:+UseAdaptiveSizePolicy参数是一个开关,打开后,会根据环境的变化,自动调节最优的eden大小和Survivor大小。以及-XX:PretenureSizeThreshod来制定几次几次回收后晋升老年代对象年龄,这个被称为GC自适应的调节策略。 - Serial Old 收集器:老年代的单线程收集器,采用标记-整理算法来实现,这个收集器使用Client模式的虚拟机使用。可配合Parallel Scanvenge收集器使用
- Parallel Old收集器:是Parallel Scanvenge收集器的老年代版本,使用多线程 和 标记整理算法来实现,在注重吞吐量以及cpu资源敏感的场合,优先推荐Parallel Scanvenge 和Parallel Old收集器。多线程是针对多核cpu而言才能提升性能,单cpu下采用多线程会存在频繁的线程上下文切换,从而降低了性能。
- CMS(Concurrent Mark Sweep:并发标记扫描)收集器:是一种以获取最短回收停顿时间为目标的收集器(关注点是gc的回收时间如何缩短),属于老年代的收集器,采用的是标记-清除算法来实现。
相对于标记整理算法,标记清除算法速度更快,从而加快垃圾回收的速度。适用于服务端特别重视服务的响应速,希望系统停顿时间更短。
CMS收集步骤有4步:- 初始标记(inital mark):标记GC Roots能直接关联到的对象,耗时短但需要暂停用户线程;
- 并发标记(concurrent mark):从GC Roots能直接关联到的对象开始遍历整个对象图,耗时长但不需要暂停用户线程。
- 重新标记(remark): 采用增量更新算法,对并发标记阶段因为用户线程运行而产生的变动的那部分对象进行重新标记,耗时比初始标记稍长且需要暂停用户线程。
- 并发清除(inital sweep):并发清除掉已经死亡的对象,耗时长但不需要暂停用户线程。
CMS优势在于并发收集,低停顿;缺点是对CPU资源非常敏感,因为其中并发标记和并发清理占用了一部分线程而导致应用程序变慢,吞吐量变低。基于标记清除算法,会产生内存碎片,但是
可以采用-XX:CMSFullGCsBeforeCompaction参数来设置多少次不压缩的Full GC后,跟着来一次带压缩的(也就是对碎片进行整理)默认值为0
- G1(garbage-first: 垃圾优先) 收集器,G1是java9服务端模式下默认的垃圾收集器,无需和其他垃圾收集器配合使用,与前面的收集器不同,它虽然采用分代收集,但是他对java堆的布局与其他处理器差别很大,它将真个java堆划分为多个大小相等的独立区域(region),不再以固定大小和固定数量来划分分代区域。每个region可以根据不同的需求来扮演新生代的Eden空间、Survivor空间和老年代空间,收集器会根据其扮演角色的不同而采用不同的收集策略。
H用来存储大对象,即大小大于或等于region一半的对象。
运行步骤:- 初始标记 (Inital Marking):标记 GC Roots 能直接关联到的对象,并且修改 TAMS(Top at Mark Start)指针的值,让下一阶段用户线程并发运行时,能够正确的在 Reigin 中分配新对象。G1 为每一个 Reigin 都设计了两个名为 TAMS 的指针,新分配的对象必须位于这两个指针位置以上,位于这两个指针位置以上的对象默认被隐式标记为存活的,不会纳入回收范围;
- 并发标记 (Concurrent Marking):从 GC Roots 能直接关联到的对象开始遍历整个对象图。遍历完成后,还需要处理 SATB 记录中变动的对象。SATB(snapshot-at-the-beginning,开始阶段快照)能够有效的解决并发标记阶段因为用户线程运行而导致的对象变动,其效率比 CMS 重新标记阶段所使用的增量更新算法效率更高;
- 最终标记 (Final Marking):对用户线程做一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的少量的 STAB 记录。虽然并发标记阶段会处理 SATB 记录,但由于处理时用户线程依然是运行中的,因此依然会有少量的变动,所以需要最终标记来处理;
筛选回收 (Live Data Counting and Evacuation):负责更新 Regin 统计数据,按照各个 Regin 的回收价值和成本进行排序,在根据用户期望的停顿时间进行来指定回收计划,可以选择任意多个 Regin 构成回收集。然后将回收集中 Regin 的存活对象复制到空的 Regin 中,再清理掉整个旧的 Regin 。此时因为涉及到存活对象的移动,所以需要暂停用户线程,并由多个收集线程并行执行。
内存分配原则:
- 对象优先在Eden分配,当Eden区没有足够的空间时,虚拟机将进行一次Minor GC(就是新生代gc)。
- 大对象直接进入老年代:大对象是指需要大量连续内存空间的java对象,最典型的就是超长字符串或者元素数量很多的数组,主要因为大对象在新生代分配时需要大量连续的内存空间,可能会导致提前触发垃圾回收,并且由于新生代的垃圾回收本身就很频繁,此时复制大对象也需要额外的性能开销。
- 存货次数到达阈值的对象进入老年代:虚拟机会给每个对象在其对象头中定义一个年龄计数器,经历一次Minor GC后存活,会被移动到Survivor中,并将其年龄+1,通过设置-XX:MaxTenuringThreshold参数设置年龄阈值,默认是15次。
- 动态年龄判断:如果在Survivor空间中相同年龄的所有对象大小总和大于Survivor空间的一半。那么年龄>=此年龄的对象直接进入老年代。无需等待到年龄阈值时才进入老年代。
- 空间担保分配:在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果条件成立,那么这一次的 Minor GC 可以确认是安全的。如果不成立,虚拟机会查看 -XX:HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于或者 -XX:HandlePromotionFailure 的值设置不允许冒险,那么就要改为进行一次 Full GC 。
类加载这里不做记录
逃逸分析:
逃逸行为主要分为以下两类:
方法逃逸:当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,此时称为方法逃逸;
线程逃逸:当一个对象在方法里面被定义后,它可能被外部线程所访问,例如赋值给可以在其他线程中访问的实例变量,此时称为线程,其逃逸程度高于方法逃逸。
// 如果这个方法的sb被其他方法所引用或者作为参数所引用,则会分配在堆上,因为分配在栈上会随着栈帧的出栈而自动销毁。
public static StringBuilder concat(String... strings) {
StringBuilder sb = new StringBuilder();
for (String string : strings) {
sb.append(string);
}
return sb; // 发生了方法逃逸
}
// 这个方法中的sb会被分配到栈上
public static String concat(String... strings) {
StringBuilder sb = new StringBuilder();
for (String string : strings) {
sb.append(string);
}
return sb.toString(); // 没有发生方法逃逸
}
如果能证明一个对象不会逃逸到方法或线程之外,或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可以为这个对象实例采取不同程序的优化:
- 栈上分配 (Stack Allocations):如果一个对象不会逃逸到线程外,那么将会在栈上分配内存来创建这个对象,而不是 Java 堆上,此时对象所占用的内存空间就会随着栈帧的出栈而销毁,从而可以减轻垃圾回收的压力。(也就是不会发生方法逃逸的对象可以直接在栈上分配,而不是在堆上,实际开发中建议尽量不要发生方法逃逸)
- 标量替换 (Scalar Replacement):如果一个数据已经无法再分解成为更小的数据类型,那么这些数据就称为标量(如 int、long 等数值类型及 reference 类型等);反之,如果一个数据可以继续分解,那它就被称为聚合量(如对象)。如果一个对象不会逃逸外方法外,那么就可以将其改为直接创建若干个被这个方法使用的成员变量来替代,从而减少内存占用。
- 同步消除 (Synchronization Elimination):如果一个变量不会逃逸出线程,那么对这个变量实施的同步措施就可以消除掉。