JVM 的组成部分
- 类加载器子系统:将编译好的 class 文件加载到 jvm 中
- 运行时数据区: 存储 jvm 运行过程中产生的数据
- 执行引擎: 包括即时编译器和垃圾回收器
- 本地接口库
- 本地方法库
JAVA内存区域与内存泄漏
内存区域
1、程序计数器
计数器记录的是正在执行的虚拟机字节码指令的地址
2、java虚拟机栈
每个方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接(例如多态就要动态链接以确定引用的状态)、方法出口等信息。
3、本地方法栈
java 虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。
4、java堆
java heap存放对象实例,也被称为GC堆。java堆可以处于物理上不连续的内存空间中,但是逻辑上是连续的(类似磁盘文件存储)。多数虚拟机出于实现简单,会要求java堆处于连接空间。
5、方法区
也被叫做Non-Heap,它用于存储已被虚拟机加载的类信息、常量(final )、静态变量(static)、即时编译器编译后的代码等数据。
6、运行时常量区
直接内存
使用native函数库直接分配的堆外内存。
NIO中,ByteBuffer有个方法是allocateDirect(int capacity) ,这是一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
JAVA对象
对象的创建
(1)一般都采用指针碰撞(已分配内存和未分配内存的分界线),当new一个对象时,移动指针。
(2)当需要new一个较大文件时,单个空闲内存不够,采用空闲列表,在空闲列表中选择多个空闲块。
具体采用那种方式需要看java堆的GC机制,当GC带有空间压缩能力(GC时移动内存块)时选择第一中方式。像CMS这种基于清除算法的GC机制,理论上只能采用空闲表(但是实际设计中,CMS设计了一种分配缓冲区,会先在空闲列表中收集较大的内存空间,也能只能采用指针碰撞的方式进行内存分配)。
注意:内存分配存在线程安全问题
解决办法:(1)采用CAS加上失败重试的方式解决
(2)每个线程在创建时预先分配一小块内存,本地线程分配缓存(Thread local allocation buffer TLAB),在缓存空间用完了,使用锁的方式分配新的内存。(开启TLAB对象在实例化时可以不赋初始值)
对象的内存布局
(1)对象头
1)Markword 用于存储hashcode、GC分代年龄、锁状态码、线程持有的锁,偏向线程ID,偏向时间戳等
2)类型指针,用于确定该对象时那个类的实例
(2)实例数据
(3)对齐填充
hotspot任何对象的大小都必须是8字节的整数倍,对象头大小为8字节的1-2倍,如果实例数据不是8字节的倍数,需要做填充
对象的定位
在java栈的本地变量表中的reference数据来中,一般有2种方式
1)reference指向句柄池中的句柄,句柄中包含对象实例数据的指针
2)reference直接指向对象实例数据的指针。
第一种方式的好处的reference指向的句柄地址是稳定的,对象移动,句柄地址不会变(改变的句柄存储的指针内容)
第二种方式的好处的效率高,没有中介。
垃圾收集器与内存分配策略
GC机制
判断对象是否存活
1)引用计数法:一个地方引用计算器+1,引用失效-1,当引用计数=0时,对象就不会在被使用了(经验)
2)可达性分析算法:GCRoots作为根节点,不在引用链上的对象被回收。
GC算法
标记清除算法Mark-Sweep
先标记出可以回收的对象,标记完成后统一回收
缺点:1)大量标记和清除工作随着对象数量上升导致效率越来越慢
2)会产生大量不碎片空间,导致大对象无法找到合适空间提前触发垃圾收集
标记复制算法
把内存按容量分成2个区域,当半区内存用完时,把还存活的对象复制到另一个半区,并清除当前半区内存。
(目前商用虚拟机的都采用这种算法去回收新时代,因为新生代98%对象都会在第一轮回收)
缺点:1)在对象存活率高的应用场景下,需要进行大量的复制操作。效率低。
2)需要一倍的额外空间
标记整理法
让所有存活对象向内存的一端移动,不直接清除可回收对象,而是当移动完成时直接清除掉存活对象边界后的内存空间。(一般用于老年代回收)
缺点:进行移动的时候需要暂停全部用户进程。(Stop the world)问题
权衡方法:在老年代中先采用标记清除算法,当内存碎片化达到一定影响后再采用标记整理法。(CMS收集器)
HotSpot的GC实现
根节点枚举(OopMaps)
根节点(GC Roots)枚举也需要暂停整个用户进程,如果需要去全局引用和执行上下文中去找根节点,在庞大的java应用中,无疑是非常慢的,所以HotSpot采用OopMaps来记录引用。一但类完成加载,hotspot就会在Oopmap中把对象在什么偏移量上是什么类型的数据记录下来,并且记录那些位置是引用。
安全点(SafePoint)
HotSpot不可能为每条指令都生成OopMaps(耗费大量内存),只有在特定位置才会生成OopMaps,被称为安全点。安全点的选择是根据指令序列的复用,列如方法调用、循环跳转、异常跳转等指令序列复用,具有这些功能的指令才会产生安全点。
中断存在问题:多线程情况下,怎么让所有线程都跑到最近的安全点停顿下来。
解决办法:1)抢断式中断:系统先中断全部线程,发现有线程不在安全点上,先让这个线程恢复,跑到安全点上再中断,最后一起响应GC。(目前没有虚拟机采用)
2)主动式中断:设置一个标志位,各个线程轮询这个标志位。标志位为真是线程在最近的安全点中断,主动挂起。轮询标志和安全点的位置重合,在安全点查看标志位
(hotspot采用test指令产生轮询效果,在安全点后面插入一条test指令,当需要中断用户线程的时候把内存页设置为不可读,test指令执行就会产生自陷指令,然后挂起)
存在问题:安全点能解决暂停用户线程问题。但是当用户线程不执行的时候(如没有分配的时间片)就没法执行到安全点,不能响应虚拟机的中断请求。
安全区域
安全区域是确保在一段代码中,引用关系不会发生变化。因此在这个区域中,任何时候发生GC都是安全的。
当线程要离开安全区域时,它要检查虚拟机是否完成了根节点的枚举,如果完成了继续执行,没有完成中断等待。
记忆集与卡集
为了解决跨代引用的时,新生代扫描GC roots时需要扫描整个老年代。创建了一个新的数据结构-记忆集。
记忆集的实现:
1)字节精度:每条记录对应一个字长。表示这个字长包含跨代引用。
2)对象精度:每条记录对应一个对象,记录这个对象的指针。表示这个对象包含跨代引用。
3)卡精度:每条记录对应一个卡表,表示这个卡表区域包含跨代引用。
CARD_Table [this address>>9] = 1; 来标记卡表。可以看出一个卡表包含2^9即512个字节。表示这512个字节空间中包含跨代引用。(一般都采用卡集记录,节省维护空间)
写屏障
为了解决实现卡集,在何时写入卡表等问题。Hotspot采用写屏障,应用AOP思想。把维护卡表看作是java赋值操作的一个AOP切面。实现在每次赋值操作时,更新卡表。
并发的可达性分析
垃圾收集器判断对象是否死亡采用可达性分析。而且可达性分析必须暂停全部用户线程,对这个操作的优化十分有必要。对于GCroot的收集由于使用了OopMaps,暂停的时间是相对固定且短暂的。根据GCroots继续遍历对象图,会随着java堆的庞大,停顿时间越来越长。所以设计收集器进程与用户进程并发执行。
采用三色标记对象: 1)白色:还未被收集器扫描过
2)灰色:被收集器扫描过,但至少还存在一个引用还没被收集器扫描(扫了一半)
3)黑色:全部引用都被收集器扫描
存在问题:对象消失(还被引用的对象被标记为了白色)
产生原因:1)赋值器插入一条或者多条从黑色对象到白色对象的引用
2)赋值器删除了全部从灰色对象到该白色对象的引用
解决办法:1)增量更新:
在收集器执行过程中,把赋值器插入从黑色对象到白色对象的引用记录下来,在这个图扫描结 束时,把黑色对象当作根节点再扫描一次。
2)原始快照:
在收集器执行过程中,把赋值器删除从灰色对象到白色对象的引用记录下来,在这个图扫描结 束时,把灰色对象当作根节点再扫描一次。
收集器发展历程
Serial/Serial Old
新生代采用标记复制算法,老年代采用标记整理算法,执行过程暂停整个用户进程
PerNew/Pernew Old
Serial的多线程版,新生代或者老年代执行算法采用多线程。
Parallel Scavenge
通过动态调整新生代空间(空间越小会导致收集频率变得频繁)来调整收集器的吞吐量=用户运行代码的时间/(用户运行代码的时间+垃圾收集的时间)
Parallel Old
Parallel Scavenge的老年代版本,支持多线程并行收集,采用标记整理算法
CMS(Concurrent Mark Sweep)
CMS采用标记清除算法,可以实现垃圾收集与用户进程并发执行,主要包括4个阶段:
1)初始标记:只标记与GCroots直接关联的对象
2)并发标记:从初始标记标记的对象遍历整个对象图
3)重新标记:解决并发标记发生的对象消失问题,采用增量更新
4)并发清除:清理需要删除的空间,由于不需要移动,可以并发执行
(初始标记和重新标记仍然需要暂停整个用户进程)
Garbage First(G1)
G1收集器的回收范围不再是整个java、新生代或者老年代。而是先把java堆分成一个个大小相等的独立区域(Region),每个Region可以是新生代或者老年代,还有专门存放大对象的Humongous Region。G1收集器建立可以可预测的停顿时间模型,根据统计学给每个回收区域一个优先级,优先回收有价值的区域(能回收垃圾多的)来保证暂停时间的稳定。
低延迟垃圾收集器
4TB以下的堆容量暂停时间不超过10ms
Shenandoah收集器
Shenandoah堆的内存分布和G1类似,也是基于Region。但shenandoah不采用记忆集去解决跨代引用,而是采用全局连接矩阵。
1)初始标记:与G1一样只标记与GCroots直接关联的对象,需要暂停整个用户进程
2)并发标记:从初始标记标记的对象遍历整个对象图,并发进行
3)最终标记:与G1一样,处理剩余的SATB扫描,并统计回收价值最高的region,将这些Region统计成一组回收集。会有一小段时间的暂停。
4)并发清除:清理那些没有一个存活对象的Region
5)并发回收:把回收集Region里面还存活的对象复制到还未分配的region里面,需要与用户进程同步进行。采取读屏障和转发指针来解决并发会引发的错误。
6)初始引用更新:并发回收阶段复制到新区域的对象,需要把旧的引用修正到新的引用地址。初始引用更新不进行修改,只是建立一个线程集合点,确保并发回收的线程都已经完成。
- 并发引用更新:更新引用
8)最终引用更新:修正GCroots中的引用问题,需要短暂停用户进程。
9)并发清理:并发清理回收集中的Region。
ZGC
并发整理阶段,ZGC采用染色指针,直接把值存在在引用指针的高4位地址中,不需要额外的存储空间。
JAVA与线程
线程和进程
进程有独立代码和数据空间,进程的切换会有比较大的开销。
线程共享进程的数据空间,有独立的运行栈和程序计数器,是最小的cpu调度单位。
内核线程的实现
1:1实现,内核线程直接由操作系统支持的线程。内核通过操作调度器对线程进行调度,调度器负责将线程的任务映射到各个处理器上。程序一般不会直接使用内核线程,而是使用内核线程的高级接口-轻量级进程。每个轻量级进程对应一个内核线程。
用户线程的实现
不需要内核支持的,需要用户自己实现线程的创建、销毁、切换和调度。
java线程的实现
一般直接直接映射操作系统线程。
协程
内核线程的局限,线程调度时候用户态到核心态的状态切换,需要响应中断、保护和恢复执行现场,需要高额的成本。所以内核线程的数目很有限。而高并发的情况下,可能由百万计的线程灌入线程池。于是引入协程。
有栈协程:完整的实现调用栈的保护、恢复工作。
无栈协程:本质是一个有限状态机,状态保存在闭包里,比有栈协程轻量,但功能有限。
java协程
纤程,有栈协程的一种特例。
线程安全
线程安全的实习方法
互斥同步(悲观锁)
java里添加synchronized。添加synchronized关键字编译后,会在代码段前后添加monitroenter和monitorexit指令,这两个指令都指向一个reference参数。monitorenter指令会对reference参数+1,monitorexit会对reference-1,当reference=0时,锁释放。
RenntrantLock时java.util.concurent.locks.Lock接口的一种实现。支持:
等待可以中断:有持有锁的线程长期不释放锁,正在等待的线程可以放弃等待,处理其他事情。
公平锁:多个线程等待同一个锁时候,需要按申请时间依次获得锁。会降低吞吐量。
锁可以绑定多个条件:
非阻塞同步(乐观锁)
使用原子操作来保证同步,如CAS
无同步方案
代码本身无共享数据,不需要同步。
锁优化
自旋锁
让线程空转,等到资源被释放,再继续执行。可以避免频繁的上下文切换。
锁消除
通过逃逸分析,判断数据是否时线程私有的,如果是私有无需加锁。
锁粗化
循环内部频繁的加锁,释放锁,大量的互斥同步导致性能消耗。虚拟机扩大锁的范围,把加锁同步扩大到整个操作序列。
轻量级锁
对象头分两部分,第一部分(mark word)储存hash码,分代年龄等。第二部分存储指向方法区对象类型的指针。mark word中有2bit的标志位,01表示未锁定,00表示轻量级锁定(并记录了指向调用栈的指针),01表示重量级锁(记录重量级锁的指针),11GC标记。每个线程的栈帧中建立了一个锁记录(Lock Record),储存锁对象目前的mark word拷贝。
锁膨胀
当标志位为01时,虚拟机可以用CAS操作把mark word更新为指向Lock Redcord的指针。成功则表示获得锁,标志位改为00。失败后会先比较markword和lock record中的记录是否相同,不同则说明别的线程占用了锁对象。这个时候就需要锁膨胀,膨胀为重量级锁,并把标志位改为10。
偏向锁
锁会偏向第一个获取锁对象的线程,如果没有其他线程请求锁,这个线程就可以一直执行。第一个获取锁对象的线程会把mark word中的1bit的偏向模式位改为1,标志位改为01,并用CAS把线程id记录到mark word中,成功则表示进入偏向模式。后续操作无需同步。一但出现另一个线程获取锁对象,偏向模式位立马变为0。
轻量级锁是把锁对象的位置放在了markword中,可以直接读取到,所以快。重量级锁markword记录的是指向重量级锁对象的指向,还需要去读取。