文章目录
参考链接:
1.java对象的四种引用:强引用、软引用、弱引用和虚引用
2.要点提炼| 理解JVM之GC&内存分配
3要点提炼| 理解JVM之类文件结构
4.JAVA中的栈和堆
Java内存管理机制
内存区域划分
- JVM执行java程序的过程:首先将.java文件编译成.class文件,再由JVM中的类加载器加载各个类的字节码文件,加载完毕之后,交由JVM引擎执行。
JVM会用一段空间来存储程序运行过程中所需要的数据和相关信息,这段空间叫做运行时数据区。JVM会把它所管理的内存划分为若干个不同的数据区域,如下图。
运行时数据区分成两类:
(1)线程私有数据区:虚拟机栈、本地方法栈、程序计数器;
(2)线程共享数据区:堆、方法区。
1.程序计算器(Program Counter Register)
它可以看做是当前线程所执行的字节码的行号指示器。字节码解释器工作时,就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
- Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何情况下,同一时刻只会执行一个线程的的指令,为了让线程切换后能到达正确位置,所以每个线程都有自己的程序计算器。
- 如果线程正在执行的是一个Java方法,那么计数器记录的是正在执行的虚拟机字节码指令的地址;
- 如果线程正在执行的是一个Native方法,那么计数器的值则为空
此内存区域是唯一一块没有规定任何OutOfMemoryError情况的区域。
2.Java虚拟机栈
虚拟机栈描述的是java方法执行的内存模型。
- 每个方法在执行时,都会创建一个栈帧,其中包括局部变量表,操作数栈、动态链接、方法出口等信息。
- 每一个方法从调用到结束的过程,就对应这一个栈帧从虚拟机栈入栈到出栈的过程。
局部变量表存储着(1)基本数据类型(int,boolean,float,char,double…)(2)对象引用(refrence)可能是直接指向对象的起始位置的指针,也可能是指向一个代表对象的句柄,具体根据访问定位。(3)returnAddress类型。
特点:
- 线程内私有
- 存在两个异常
- StackOverFlow异常:如果线程请求的栈深度超过虚拟机栈的深度;
- OutOfMemoryError异常:虚拟机在动态扩展时,如果无法申请到足够的内存,会抛出异常。
3.本地方法栈
与java虚拟机栈相似,只不过java虚拟机栈是为java方法服务,而本地方法栈是为本地方法服务,同样会抛出StackOverFlow与OutOfMemoryError异常。
4.java堆
- java堆用于存放所有的对象实例和数组。
- 是java虚拟机所管理的内存最大的一块。
- 被所有线程所共用。
java堆是垃圾回收器管理的主要区域。
- 细分有新生代与老年代
- 在具体分的话,Eden空间、To Survivor空间、From Survivor空间(应用于新生代区域的GC复制回收算法)
- java堆可以分成多个线程私有的分配缓冲区(Thread Local Allocation Buffer TLAB)
除此之外,还有:
- 在存储时可以物理上不连续,逻辑上连续即可
- 会抛出OOM异常,当堆中没有内存完成实例分配,并且堆也无法再扩展时。
5.方法区
- 用于存储被虚拟机加载的类型信息、常量、静态变量等。(其中类型信息是指:类名,父类名,方法名等等)
- 与Java堆类似被所有线程所共有
- 又名永久代
- 与Java堆相同,可以不选择连续存储,或选择固定大小存储,可扩展;除此之外还可以选择不实现GC。
- 当方法区无法满足内存分配时,则会抛出OOM异常。
6.运行时常量池
用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
- 是方法区的一部分
- 动态性,不要求常量一定只有编译期才能产生,也就是说并非只有预置在class文件常量池中的常量才能进入运行时常量池,而在运行期间也可以将新的常量放入运行时常量池。
- 同样具有OOM异常。
虚拟机对象探秘
对象创建
以new操作为例。
(1)类加载检查:检查该指令的的参数是否能在常量池中找到一个类的符号引用,同时这个符号引用所代表的类是否完成了加载、解析、初始化;没有则将进行类加载。
(2)分配内存:由虚拟机为对象分配内存,等同于把一块确定大小的内存从java堆中划分出来。
分配内存一共有两种方式,根据堆中内存是否规整来判断使用哪一种方式。
- 如果内存规整(用过没用过的内存分别在两边)
规整的意思是所有用过的内存放在一边,所有没用过的放在另一边,中间放着指针,可以通过移动指针来给新对象分配内存,只要将指针向没用过的方法移动与对象长度相同的大小即可。这种方式叫做碰撞指针。 - 如果内存不完整(用过没用过的混在一起)
如果用过的内存与没用过的内存混在一起,虚拟机需要维护一个列表,记录哪些内存是可以使用的。在给对象分配内存时,要到列表中找到一个比当前对象长度大的位置区存放对象实例。这种方式叫空闲列表。
除了划分空间外,还有一点是要保证线程安全。(对象创建在虚拟机中十分频繁,可能出现正在给对象A分配内存, 指针还没来得及修改, 对象B又同时使用了原来的指针来分配内存的情况)同样有两种方式解决线程安全问题。
- 对内存分配的动作进行同步处理;
- 把内存分配的动作按照线程划分在不同的空间执行,也就是不同线程在堆中预先分配了自己的缓冲区(Thread Local Allocation Buffer TLAB),并在自己的缓冲区上分配。当TLAB用完需要分配新的TLAB时,在进行同步操作。
内存分配完成后, 虚拟机需要将分配到的内存空间都初始化为零值( 不包括对象头),这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用, 程序能访问到这些字段的数据类型所对应的零值。
(3)对象头的设置,例如:该对象是哪个类的实例,找到类的元数据信息的方式、对象的哈希码、对象的GC分代年龄等信息存放在对象的对象头中。
(4)最后执行<init>
操作,把对象按照程序员的意愿进行初始化。
对象的内存布局
对象在内存中,存储的布局包括:对象头、实例数据、对齐填充。
对象头中的数据包括两部分:
- 存储对象自身的运行时数据:HashCode、GC分代年龄等等;
- 类型指针:根据该类型指针就可以知道对象所属的类。但不是每个对象都需要有类型指针。
实例数据:对象真正存储的有效信息。
对其填充:仅仅起到占位符的作用,虚拟机中要求对象的大小必须是8的整数倍,对其填充是用于补齐的,
对象的访问方式
1.使用句柄访问,java堆会划分出一块内存作为句柄池。reference(对象)就是指向了代表对象的句柄池,句柄池包括对象实例数据的指针与对象类型数据的指针。
2.直接引用
refrence中存储的就是对象的地址,而java堆中就必须考虑如何放置访问类型数据的相关信息。
优劣比较:refrence存放的是稳定的句柄,不需要改变,当对象被移动时,只需要更改句柄池中的实例对象指针即可。
使用直接指针访问方式的最大好处就是速度更快, 它节省了一次指针定位的时间开销。
垃圾收集器与内存分配
判断对象是否死亡
引用计数法与可达性分析
1.引用计数方法
使用计数器,每当对象被引用就计算+1,当引用失效就-1,当引用为0时就证明该对象死亡。但问题在于两个对象有字段在互相引用,之后将对象置为null,此时对象其实已经死亡,但计算器仍然不为1,如下图。
2.可达性分析算法
通过一系列被称为GC Roots的节点,向下寻找,搜索所走过的路叫做引用链,如果一个对象与GC roots没有一条引用链相连,则判断该对象死亡。
java虚拟机中可以作为GC ROOTs的节点包括:
- 虚拟机栈中栈帧中的本地变量表引用的对象;
- 方法区中常量引用的对象;
- 方法区中的静态属性引用的对象;
四种引用
java中对象存在四种引用类型。
- 强引用:诸如:
Object obj = new Object();
,强引用的对象不会被GC回收; - 软引用:有用但是不是必要的;会在内存空间不足的时候被GC,如果回收之后内存仍不足,才会抛出OOM异常;
- 弱引用,与软引用类似,但是优先级低于软引用,不管内存是否够用,在GC时都会被直接回收;
- 虚引用,仅持有虚引用的对象,在任何时候都可能被GC;作用在于可以当对象回收时,会返回一个信息。
finalize方法
判断对象是否死亡,**不会只通过可达性分析,而是还会根据是否可以调用finalize方法。**如果可达性分析无法到达GC Node,且不可以调用finalize,才是真正的死亡。
是否可以调用finalize方法由以下两方面决定:
- jvm是否调用过finalize方法
- 是否实现finalize方法
在finalize中可以实现自救,只要有任何引用链中的对象引用了该对象即可,这样就自救成功,但只可以调用一次finalize方法。
GC方法
我们讨论的时堆中的垃圾收集算法。
1.标记-清除算法
标记出哪些对象需要删除,之后回收所有被标记的对象。
(1)效率较低,标记与清除浪费时间。
(2)这种算法会导致大量的空间碎片,存储对象的内存和未存储对象的内存就变得连续在一起了,会导致之后存储大对象时,会因为找不到一块可以存放的内存,而再次GC。
2.复制算法
将内存等分成两部分,当一部分存储满了之后,就将该部分中存活的对象都复制到另一部分中,然后将该部分的内存全部清除。这样做的好处就是没有了空间碎片,但每次使用的内存只有50%。
应对该算法的改进是由于新生代的对象存活时间短,因此将内存区分成Eden,两个Survrior,比例是8:1:1,每次使用90%的内存去存储。当回收时,会将他们其中的存活对象放入另一个Survrior中,最后清理掉Eden与刚刚用过的Surviror内存。
3.标记-整理算法
首先标记需要删除的对象,之后将所有存活的对象都移动到一边,然后对边界外的内存进行清理。
4.分代算法
分代算法将标记-移动算法与复制算法相结合,根据对象存活周期的不同,将Java堆划分为新生代和老年代,并根据各个年代的特点采用最适当的收集算法。
- 新生代中对象大量死去,因此则使用复制算法;
- 老年代中对象存活率高,因此使用标记-整理算法或标记-清除算法。
HotSpot的算法实现
主要包括了如何枚举根节点,以便进行判断对象是否存活,以及该在什么地点或区域进行GC。
1.枚举根节点
GCRoot一般在全局性引用(常量或静态属性)或上下文(桢栈中的本地变量表)的引用位置,可达性分析对时间的敏感主要体现在GC停顿,而GC停顿主要就在枚举根节点上。
虚拟机中一般使用准确式GC,可以得知所有全局和上下文的引用位置,在HotSpot中是使用OopMap实现的该功能。完成类加载后会计算出对象某偏移量上某类型数据,**JIT编译时会在特定的位置记录栈和寄存器中是引用的位置。**这样GC在扫描时就可直接得知这些信息,并快速准确地完成GC Roots的枚举。
2.安全点
JIT并不会在所有位置都记录,而只是会在特点的位置记录,这个记录的位置叫做安全点。程序只会在安全点之后暂停并进行GC。安全点设置不能太少或太多,而选择的标准为是否具有让程序长时间执行的特征,如方法调用、循环跳转、异常跳转。
当GC发生时,有两种方法使所有线程都走到中断点。
(1)抢先式中断:不需要代码配合,立即停止所有线程,如果有线程没有走到中断点,则开启该线程让线程走到中断点。
(2)主动式中断:设置一个标记位,各个线程执行时轮询该标志位,如果为真则自己主动挂起,标记位与中断点重合。
3.安全区域
上述安全点是运行在线程运行的状态下,如果是线程不运行就不可以了。而安全区域是在区域内都可以进行GC。
安全区域是指在一段代码片段之中, 引用关系不会发生变化。 在这个区域中的任意地方开始GC都是安全的。
当线程运行到Safe Region中时,会将线程标记为Safe Region,这样的线程在JVM发起GC时就不会处理。当线程从Safe Region中离开时会判断是否完成GC,完成则会继续执行其他操作,否则就要执行。
主要的垃圾回收器
并行是指:多条线程同时进行GC,但用户线程是停止的;
并发是指:GC线程与用户线程是同时或有顺序交替执行的;
内存分配与回收策略
自动内存管理是指:给对象自动分配内存并自动回收不需要的对象的内存。
对象的分配是指在堆上的分配,对象主要分配在新生代的Eden区中,如果启动了线程本地内存缓冲,则优先存放在TLAB中。最后则存放在老年代中。
内存分配遵循以下几条规则。
-
1.对象优先在新生代的Eden中存放
大多数情况下, 对象在新生代Eden区中分配。 当Eden区没有足够空间进行分配时, 虚拟机将发起一次MinorGC。
MinorGC:指新生代区发生的GC,一般速度较快;
FullGC:指老年代内存发生的GC,速度会比MinorGC慢上10倍; -
2.大对象直接进入老年代
可以设置属性PretenureSizeThreshold,当对象大小大于一定时,直接放入老年代。这样做的目的是防止Eden区与Survivor区发生大量的内存复制。 -
3.长期存活的对象直接放入老年代
由于Minor GC经常发生,我们会在将存活对象放入Survior的同时,将其age增1。我们可以通过设置属性MaxTenuringThreshold(默认为15),当age大于多少时可以将对象放入老年代。 -
4.动态年龄判断
存活年龄相同的对象的大小之后如果大于当前Survivor容量的一半则将大于等于该对象年龄的对象放入老年代。 -
5.空间分配担保
当Minor GC之后,会将仍然存活的对象放入Survivor中,如果Survivor存储不下的话,就会挪用老年代的空间。
因此, 在MinorGC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间, 如果这个条件成立, 那么Minor GC可以确保是安全的,否则会判断老年代是否进行担保(HandlePromotionFailure)。
如果担保,则会根据平均分配到存活对象来比较老年代所剩余的空间,如果可以存放,则执行一次冒险的MinorGC ,如果不够存放,则会直接进行一次Full GC。