Java JVM一
1. 内存模型以及分区,需要详细到每个区放什么
- 程序计数器(Program Counter Register)
是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果正在执行的是 Native 方法,这个计数器值则为空。
- Java 虚拟机栈(Java Virtual Machine Stacks)
JVM Stack 也是线程私有的,它的生命周期与线程相同。
虚拟机栈描述的是 Java 方法执行的内存模型。
每个方法在执行时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等
信息。
每一个方法从调用直至执行完成的过程,就对应一个栈帧在虚拟机栈中的入栈和出栈。
局部变量表存放了编译期可知的各种基本数据类型、对象引用和 returnAddress 类型。
其中 64 位长度的 long 和 double 类型的数据会占用 2 个局部变量空间(slot),取余的数据类型只占用 1 个。
局部变量表所需要的内存空间在编译期完成分配,在方法的运行期不会改变局部变量表的大小。
StackOverflowError
如果线程请求的栈深度大于虚拟机所允许的深度。
OutOfMemoryError
虚拟机栈可以动态扩展,如果扩展时无法申请到足够的内存。
- 本地方法栈(Native Method Stack)
本地方法栈与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。
- Java 堆(Java Heap)
Java 堆是 JVM 所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例。
所有的对象实例以及数组都要在堆上分配,但随着JIT(即时编译器)编译器
的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么绝对了。
Java 堆是垃圾回收器管理的主要区域,因此很多时候也被称做 GC堆(Garbage Collected Heap)。
Java 堆可以是规定大小的,也可以是可扩展的。当前主流的虚拟机都是按照可扩展来实现的(通过 -Xmx 和 -Xms 控制)。
OutOfMemoryError 堆中没有内存完成实例分配。
- 方法区(Method Area)
方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、及时编译器编译后的代码等数据。
可以选择固定大小和可扩展,可以选择不实现垃圾收集。
OutOfMemoryError 方法区无法满足内存分配需求。
- 运行时常量池(Runtime Constant Pool)
运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
运行时常量池相对于 Class 文件常量池的另一个重要特征是具备动态性,运行期也可以将常量放入池中,如 String 类 的 intern 方法。
OutOfMemoryError 常量池无法满足内存分配需求。
- 直接内存(Direct Memory)
直接内存并不是虚拟机运行时数据区的一部分。但是同样可以导致 OutOfMemoryError 异常出现。
JDK1.4 中新加入了 NIO(New Input/Output)类,引入了一种基于通道和缓冲区的 IO 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的DirectByteBuffer
对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
2. 堆里面的分区:Eden,survival from to,老年代,各自的特点
- 分代回收算法
一般是把 Java 堆分为新生代和老年代,这样可以根据各个年代的特点采用最适当的收集算法。
- Eden区
Eden区位于Java堆的年轻代,是新对象分配内存的地方,由于堆是所有线程共享的,因此在堆上分配内存需要加锁。而Sun JDK为提升效率,会为每个新建的线程在Eden上分配一块独立的空间由该线程独享,这块空间称为TLAB(Thread Local Allocation Buffer)。在TLAB上分配内存不需要加锁,因此JVM在给线程中的对象分配内存时会尽量在TLAB上分配。如果对象过大或TLAB用完,则仍然在堆上进行分配。如果Eden区内存也用完了,则会进行一次Minor GC(young GC)。
- Survival from to
Survival区与Eden区相同都在Java堆的年轻代。Survival区有两块,一块称为from区,另一块为to区,这两个区是相对的,在发生一次Minor GC后,from区就会和to区互换。在发生Minor GC时,Eden区和Survival from区会把一些仍然存活的对象复制进Survival to区,并清除内存。Survival to区会把一些存活得足够旧的对象移至年老代。
- 年老代
年老代里存放的都是存活时间较久的,大小较大的对象,因此年老代使用标记整理算法。当年老代容量满的时候,会触发一次Major GC(full GC),回收年老代和年轻代中不再被使用的对象资源。
3. 对象的创建方法,对象的内存布局,对象的访问定位
3.1 对象的创建方法
对象所需内存的大小在类加载完成后便完全确定(对象内存布局),为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。
根据Java堆中是否规整有两种内存的分配方式:(Java堆是否规整由所采用的垃圾收集器是否带有压缩整理功能决定)
- 指针碰撞(Bump the pointer) :
Java堆中的内存是规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,分配内存也就是把指针向空闲空间那边移动一段与内存大小相等的距离。例如:Serial、ParNew等收集器。
- 空闲列表(Free List) :
Java堆中的内存不是规整的,已使用的内存和空闲的内存相互交错,就没有办法简单的进行指针碰撞了。虚拟机必须维护一张列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。例如:CMS这种基于Mark-Sweep算法的收集器。
3.1.1 并发处理
对象创建在虚拟机中时非常频繁的行为,即使是仅仅修改一个指针指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
- 同步
虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。
- 本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)
把内存分配的动作按照线程划分为在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存(TLAB)。哪个线程要分配内存,就在哪个线程的TLAB上分配。只有TLAB用完并分配新的TLAB时,才需要同步锁定。
3.2 对象的内存布局
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
3.2.1 对象头
HotSpot虚拟机的对象头包括两部分信息:运行时数据和类型指针。
- 运行时数据
用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。
- 类型指针
即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。
(并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据并不一定要经过对象本身,可参考对象的访问定位)
3.2.2 实例数据
实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类中继承下来的,还是在子类中定义的,都需要记录下来。
HotSpot虚拟机默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oop,从分配策略中可以看出,相同宽度的字段总是分配到一起。
3.2.3 对齐填充
HotSpot虚拟机要求对象的起始地址必须是8字节的整数倍,也就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐的时候,就需要通过对齐填充来补全。
3.3 对象的访问定位
Java程序需要通过栈上的引用数据来操作堆上的具体对象。对象的访问方式取决于虚拟机实现,目前主流的访问方式有使用句柄和直接指针两种。
句柄,可以理解为指向指针的指针,维护指向对象的指针变化,而对象的句柄本身不发生变化;
指针,指向对象,代表对象的内存地址。
3.3.1 句柄
Java堆中划分出一块内存来作为句柄池,引用中存储对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
优势:引用中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而引用本身不需要修改。
3.3.2 直接指针
如果使用直接指针访问,那么Java堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而引用中存储的直接就是对象地址。
优势:速度更快,节省了一次指针定位的时间开销。由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是非常可观的执行成本。(例如HotSpot)
4. GC的两种判定方法:引用计数与可达性分析算法(对象已死的判断方式)
4.1 引用计数
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。
Java 虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。
4.2 可达性分析算法(引用链)
通过一系列的称为 GC Roots 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连(用图论的化来说,就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的。
在 Java 语言中,可作为 GC Roots 的对象包括下面几种:
1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
2. 方法区静态属性引用的对象
3. 方法区中常量引用的对象
4. 本地方法栈中引用的对象
四类引用:强引用、软引用、弱引用、虚引用(唯一的目的是能在这个对象被收集器回收时收到一个系统通知)
5. GC的三种收集方法:标记清除、复制算法、标记整理、分代收集算法的原理与特点
5.1 标记清除
如同它的名字一样,算法分为标记和清楚两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有标记的对象。
不足:
一个是效率问题,标记和清除两个过程的效率都不高;
另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
5.2 复制算法
为了解决小路问题,复制算法出现了,它将可用的内存按容量分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。
这种算法的代价是将内存缩小为原来的一半。
5.3 标记整理
复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。老年代一般不能直接选用这种算法。
根据老年代的特点,提出一种标记整理算法,标记过程仍然和标记清除算法一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
5.4 分代收集算法
当前商业虚拟机的垃圾回收都采用分代收集算法,根据对象存活周期不同将内存划分为几块。一般是把 Java 堆分为新生代和老年代,这样就可根据各个年代的特点采用最适合的收集算法。
在新生代中,每次垃圾收集都发现有大批对象死去,只有少量存活,那就选用复制算法。
在老年代中,因为对象存活率高,没有额外空间对它进行分配担保,就必须使用标记清除或者标记整理算法来进行回收。