资深架构师深入专研JVM的个人理解以及整理

java虚拟机所管理的内存区域划分为堆,方法区,虚拟机栈,本地方法栈,程序计数器。

每个虚拟机栈中有一个私有的程序计数器,程序计数器占用很小的一块内存,在执行一个java方法时,记录正在执行的虚拟机字节码的地址。虚拟机栈中有一个栈帧,用于存放局部变量表,操作数栈,动态链表,方法出口等。

1. Java内存区域与内存溢出异常

1.1. 运行时数据区域

(1) 程序计数器:程序计数器用来记录当前线程执行的字节码行号。程序计数器是线程私有的,因为CPU通过时间轮转来为线程服务,为了线程切换后能够恢复的正确的位置,在每一个线程都保存一个程序计数器。如果执行的是本地方法则,程序计数器值为空。

(2) Java虚拟机栈:Java虚拟机栈是Java方法的内存模型,每一个方法被执行的过程都会创建一个栈帧用来存储局部变量表、操作栈、动态链接、方法出口等信息。局部变量表所需的内存空间是在编译时期分配的。Java虚拟机栈是线程私有的。如果申请的栈深度超过了虚拟机允许的最大栈深度会抛出Stack OverflowError。如果允许扩展时,当扩展时无法申请足够的内存会抛出OutOfMemoryError。使用-Xss来设置栈大小。

(3) 本地方法栈:与Java虚拟机栈相似,只不过是为本地方法服务的。

(4) 堆:Java堆是内存中线程共享的一块区域,在Java虚拟机启动的时候创建的。Java中的所有对象和数组都要在堆上分配。堆内存是GC的主要区域。由于GC是按代回收,所以堆还可以被细分为新生代和老年代。新生代又可以被细分为Eden,FromSurvivor和ToSurvivor区域。使用-Xms和-Xmx来设置堆的下限和上限。如果堆内存中没有足够的空间完成实例分配,并且也没法扩展就会抛出OutOfMemoryError异常。

(5) 方法区:是线程共享的一块区域。主要用来存储已被加载的类信息,常量,静态变量,即时编译器编译的代码。方法区一般被成为永久代,在这个区域也会进行垃圾回收,主要回收的是常量池,和对类型的卸载。方法区会出现OutOfMemoryError异常。使用-XX:PermSize和-XX:MaxPermSize设置方法区的大小。

(6) 运行时常量池:是方法区中的一部分,已被加载的类信息包括,类的版本,字段,方法,接口等描述信息还有常量池。常量池用于存储编译时期生成的字面量,符号引用,在类加载后存放到方法区的常量池中。运行时也可以将新的常量加入到常量池中,String类的intern()方法。

2. 垃圾收集器与内存分配策略

2.1. 如何判断对象已死?

2.1.1. 两种判断方法

(1) 引用计数算法:在对象中保存一个引用计数器,如果该对象在一个地方被引用,则引用计数器值加1,如果有一个地方的引用失效则计数器减1。在任意时刻计数器的值为0则表示对象已死。优点是简单,速度快。缺点是:循环引用问题。

(2) 根搜索算法:当一个对象到GC Roots没有任何引用链,则表示该对象已死。

2.1.2. 哪些对象可以当做GC Roots

(1) Java虚拟机栈中的引用的对象。

(2) 本地方法栈中的引用的对象。

(3) 方法区中常量引用的对象。

(4) 方法区中静态变量引用的对象。

2.1.3. Java中的四种引用

(1) 强引用:例如Object obj = new Object();只要强引用还在,则对象一定不会被回收

(2) 软引用:当将要发生内存溢出时,GC则将这些对象列入垃圾回收的范围。如果回收后仍然内存空间不足,则抛出OutOfMemoryError异常。

(3) 弱引用:弱引用关联的对象只能活到下一次垃圾回收之前。

(4) 虚引用:虚引用完全不影响对象的生存周期,只是在垃圾回收时收到一个系统通知。

2.1.4. 对象的二次标记

当对象到GC Roots不可达时,并不一定被回收。还回经历两次标记的过程。

当对象到GC Roots不可达时,它会被第一次标记,并被筛选。筛选的条件是是否有必要执行finalize(), 如果对象没有覆finalize()或者已经被JVM执行了finalize(),则认为没有必要执行(直接被回收)。如果认为有必要执行,则将对象存放到一个F-Queue队列中,JVM会自动创建一个低级线程Finalizer用来执行finalize()。这里执行只是触发该方法,并不会等待该方法执行完成。执行finalize()方法是对象逃脱别回收的最后一次机会。GC 会对F-Queue中的对象进行二次标记,如果在期间被GC Roots引用链上的对象重新连接,则不会被回收。

2.2. 方法区的回收

方法区主要回收常量池和无用的类。

2.2.1. 如何判断一个类是无用的类

要满足下面三个条件:

(1) 该类的所有对象都已经被回收,Java堆中没有该类的任何实例。

(2) 该类的类加载器已经被回收。

(3) 该类的java.lang.Class对象没有在任何地方被使用。没有在任何地方通过反射访问该类的方法。

2.3. 垃圾回收算法

(1) 标记清除算法:第一步将所有要回收的对象进行标记,第二步回收掉所有被标记的对象。优点:简单;缺点:标记和清除效率都较低,并且会使得内存中出现很对碎片。

(2) 复制算法:将内存区域分成一个较大的Eden区域,两个较小的Survivor区域。分配空间时,每次使用Eden区域和其中一块Survivor区域。在垃圾回收时,将Eden区域和Survivor区域存活的对象复制到另一块Survivor中。

(3) 标记-整理算法:第一步对所有要回收对对象进行标记,第二步将存活的对象移到一端,将边界以外的所有对象回收。

(4) 分代收集算法。按照对象的生存周期将内存分成几个区域。在每个区域使用不同的算法。一般把Java堆分成新生代和老年代,对新生代使用复制算法,对老年代使用标记清除或者标记整理算法。

2.4. 垃圾收集器

(1) Serial收集器:是单线程的,使用的是复制算法。使用一条线程去垃圾回收时,必须要停止其他工作线程。

(2) ParNew收集器:是Serial的多线程版本。

(3) Parallel Scavenge:目标主要是用来控制CPU的吞吐量。使用的是复制算法

(4) Serial Old收集器:Serial的老年版本。使用的是标记整理算法。

(5) Parallel Old收集器:Parallel Scavenge的老年版本。使用的是标记整理算法。

(6) CMS收集器:以获取最短停顿时间为目标的。使用的是标记清除算法。在标记和清除阶段使用的是并发操作。

(7) G1收集器:将Java堆分成若干个大小固定的区域,使用的是标记清除算法。跟踪没一个区域的垃圾堆积程度,并维持一个优先级队列,根据允许的收集时间,选择垃圾堆积最多的区域进行回收。

2.5. 内存分配与回收策略

2.5.1. 对象优先在Eden区域分配

对象优先在Eden区域分配,如果Eden区域没有足够的空间分配,则虚拟机发起一次Minor GC。-XX:SurvivorRatio用来设置Eden区域和Survivor区域的大小比值。

Eden区域空间不足,发起一次Minor GC,将Eden区域和Survivor中存活的对象复制到另一个Survivor中,如果Survivor无法容纳所有存活的对象,则根据分配担保机制,将其转移到老年代。

2.5.2. 大对象直接进入老年代

大对象指需要大量连续内存空间的对象,例如大数组。使用-XX:PretenureSizeThreshold来设置阈值,如果对象答案与这个阈值则直接进入老年代。这样做的目的避免对象在Eden区域以及两个Survivor区域发生大量拷贝。

2.5.3. 长期存活的对象进入老年代

Java虚拟机给每个对象定义一个Age计数器,如果对象在Eden区域出生,经过一次GC仍然存活,将其复制到Survivor区域,如果能被容纳则将Age加1。当Age的值大于等于MaxTenuringThreshold时进入老年代。阈值设置使用-XX:MaxTenuringThreshold。

2.5.4. 动态对象年龄判定

对应并不总是等到年龄大于maxTenuringThreshold才进入老年代。如果Survivor中相同年龄的对象的总大小大于等于Survivor空间的一半,则将所有大于等于该年龄的对象移入到老年代。

2.5.5. 空间分配担保

在发生minor GC之前,Java虚拟机会检测之前每次进入老年代的平均大小是否大于老年代的剩余大小。如果大于老年代的大小,则将进行一次Full GC。如果不大于,则查看HandlePromotionFailure是否设置为true, 若果是则进行一次minor GC. 如果为False则进行一次Full GC。

3.个人心得总结

常量池用于存放编译期期间生成的各种字面量和符号,在类加载后进入方法区的运行时常量池。

Java语言并不要求常量一定在编译期才能产生。并非一定是在class编译期中预置的才能进去方法区中的运行时常量池,在运行期间也可以将常量放入。运用的最多的就是String类的intern()方法。运行时常量池是方法区的一部分,当无法申请的内存是会抛出OOS异常。

对象的创建,当遇到一个new指令时,会先去检查这个指令的参数在常量池中是否能定位到一个类的符号引用,在检查这个符号引用代表的类是否被加载,解析和初始化够。在类加载通过后,对象所需要的大小可以完全确定。在Java堆中,拥有一个指针座位分界点的指示器,当需要分配内存时,会指向未被分配内存的区域,将其挪动一段与对象大小相等的距离,称为指针碰撞。如果内存不是规整的,会有一个列表,上面记录哪些区域是可用的,当需要是划分列表中一块足够大的空间分配给对象,然后更新列表上的记录,称为空闲列表。Java堆是否规整由GC收集器是否带有压缩整理功能决定。Serial等带有Compact过程的采用指针碰撞,CMS这种基于Mark-Sweep算法的收集器,采用空闲列表。

对象在内存中存储的布局可以分为3部分:对象头,实例数据,对齐填充。

对象头包含两部分,第一部分是自己运行时的数据,包裹哈希吗,GC分代年龄,锁状态的标志,线程自带的锁,偏向线程ID,偏向时间戳等。另一部分是类型指针,指向它的类元数据。虚拟机可以通过这个指针来确定它是哪个类的实例。如果是Java数组的话,在对象头中还必须有一块用于用于记录数组长度的数据,虚拟机可以通过普通java对象的元数据确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。

对齐填充并不是必然存在的,起到一个占位符的作用,HotSpot VM的自动内存管理系统要求对象的起始地址必须是8字节的倍数,当对象的实例数据没有对齐的时候,就需要对齐填充来补全。

实例数据是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。存储顺序受到虚拟机分配策略参数和字段在java源码中定义顺序的影响。相同宽度的字段总是被分配到一起。满足这个条件下,父类中定义的方法会出现在子类之前。如果CompactFields为true,子类中较窄的变量也可能会插入到父类变量的空隙之中。

在jdk1.6之前,StringBuilder会在java堆中创建一个实例,调用String.intern()会把这个实例复制在方法区,所以他们不是一个相同的引用,在jdk1.7后,不会再实现复制,而是在方法区中记录首次出现的实例引用。

猜你喜欢

转载自blog.csdn.net/qq_42755528/article/details/88718539