本文整理自网络和书籍。
自动内存管理
Java内存分配与管理是Java的核心技术之一,一般来说,Java在内存分配会涉及到以下区域:
区域 | 说明 |
---|---|
寄存器 | 我们在程序中无法控制。 |
栈 | 存放基本类型的数据和对象的引用,但对象本身不存放在栈中,而是存放在堆中。 |
堆 | 存放new产生的数据。 |
静态域 | 存放在对象中用static定义的静态成员。 |
常量池 | 存放常量。 |
非RAM存储 | 硬盘等永久存储空间。 |
Java运行时的数据区域
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。
注意:图片来自《深入理解Java虚拟机-JVM高级特性与最佳实践
》
程序计数器
- 全称:Program Counter Register
- 是一块较小的内存空间,它的作用可以看作是当前线程所执行的字节码的行数指示器。
- 程序计数器是当前线程所执行的行号指示器。字节解释器就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
- Java虚拟机多线程是通过线程轮流切换并分配处理器执行时间的方式实现的。为了线程切换能回复到正确的位置,每条线程都需要一个独立的线程计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
- 这个内存区域是唯一一个在Java虚拟机中没有规定任何OutOfMemoryError情况的区域。
栈
在函数中定义的一些基本类型的变量数据,还有对象的引用变量都在函数的栈内存中分配。当在一段代码中定义一个变量的时候,Java就在栈中为这个变量分配内存空间,当该变量退出作用域的时候,Java会自动释放掉为该变量所分配的内存空间。
栈也叫栈内存,是Java程序的运行区,是在线程创建的时候创建的。它的生命周期是随着线程的生命期,线程结束栈内存也就释放了,对于栈来说不存在垃圾回收的问题,只要线程一结束,该栈就结束了。
Java虚拟机栈
- 全称:Java Virtual Machine Stacks
- 线程私有,生命周期和线程相同。
- 每个方法(Method)被执行的时候都会同时创建一个栈帧(Stack Frame),栈帧是一个内存区块,是一个数据集,是一个有关方法(Method)和运行期数据的数据集,用于存储局部变量表、操作栈、动态链接、方法出口等信息。每个方法被调用直至完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
- 通常说的栈就是指局部变量表部分,存放编译期间可知的8种基本数据类型,及对象引用和指令地址。局部变量表是在编译期间完成分配,当进入一个方法时,这个栈中的局部变量分配内存大小是确定的。
- 会有两种异常
StackOverFlowError
和OutOfMemoneyError
。当线程请求栈深度大于虚拟机所允许的深度就会抛出StackOverFlowError
错误;虚拟机栈动态扩展,当扩展无法申请到足够的内存空间时候,抛出OutOfMemoneyError
。
在一个栈中有两个栈帧,栈帧2是最先被调用的方法,先入栈,然后方法2又调用了方法1,栈帧1处于栈顶的位置,栈帧2处于栈底,执行完毕后,依次弹出栈帧1和栈帧2,线程结束,栈释放。
栈帧数据 | 说明 |
---|---|
本地变量(Local Variables) | 包括输入参数和输出参数以及方法内的变量。 |
栈操作(Operand Stack) | 记录出栈和入栈的操作。 |
栈帧数据(Frame Data) | 包括类文件、方法等。 |
本地方法栈
- 全称:Native Method Stacks
- 为虚拟机使用到的Native方法服务。
- 会有两种异常
StackOverFlowError
和OutOfMemoneyError
。
堆
堆内存用来存放由关键字new创建的对象和数组。在堆中分配的内存,由Java虚拟机的自动垃圾回收器来管理。
在堆中产生了一个数组和对象后,还可以在栈中定义一个特殊的变量,让栈中这个变量的取值等于数组或者对象在堆内存中的首地址,栈中的这个变量就成了数组或者对象的引用变量。引用变量就相当于是为数组或对象起的一个名称,以后就可以在程序中使用栈中的引用变量来访问堆中的数组或者对象。
引用变量是普通的变量,定义时在栈中分配,引用变量在程序运行到其作用域之后被释放。而数组和对象本身在堆中分配,即使程序运行到使用new产生数组或者对象的语句所在的代码块之外,数组和对象本身占据的内存不会被释放,数组和对象在没有引用变量指向它的时候,才会变为垃圾,不能再被使用,但仍然占据内存空间不放,在随后的一个不确定的时间被垃圾回收器回收(释放掉)。这也是Java占内存的原因。
实际上,栈中的变量指向堆内存中的变量,这就是Java中的指针。
- 全称:Java Heap
- 对于大多数应用来说,Java堆是Java虚拟机所管理的内存中最大的一块。
- Java 堆是被所有线程共享的一块内存区域,由虚拟机启动时创建。
- Java 堆的唯一目的就是存放对象实例。
- Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称作“GC堆”(
Garbage Collected Heap
)。更细一点年轻代又分为Eden区最要放新创建对象,其中From survivor
和To survivor
保存gc后幸存下的对象,默认情况下各自占比 8 : 1 : 1 8:1:1 8:1:1。
区域 | 说明 |
---|---|
eden + S0 + S1 | 新生代(伊甸园、from区、to区,默认比例为8:1:1) |
S0 | 放置存活的对象,轮转表示from区、to区。 |
S1 | 放置存活的对象,轮转表示from区、to区。 |
tenuren | 老年代,用于保养从新生区筛选出来的Java对象,一般池对象都在这个区域活跃。 |
premanent | 永生代,或者叫做永久存储区域,用于存放JDK自身所携带的Class Interface的元数据。也就是说,它存储的是运行环境必需的类信息,被装载进此区域的数据是不会被垃圾回收器回收掉的,关闭Java虚拟机才会释放此区域所占用的内存。 |
老年代的晋升
晋升规则 | 说明 |
---|---|
提升(Promotion) | 如果对象够老,会通过“提升”进入老年代。(默认是存活15轮)。 |
分配担保 | 当 Survivor 空间不够,老年代会进行分配担保,对象会直接在老年代上分配。 |
大对象直接在老年代分配 | 超出某个大小的对象将直接在老年代分配。默认为 0,意思是全部首选 Eden 区进行分配。 |
动态对象年龄判定 | 有的垃圾回收算法,并不要求 age 必须达到 15 才能晋升到老年代,它会使用一些动态的计算方法。 |
方法区
- 全称:Method Area
- 是各个线程共享的内存区域,用于存放已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
- 被Java虚拟机描述为堆的一个逻辑部分。习惯是也叫它永久代(
permanment generation
) - 垃圾回收很少光顾这个区域,不过也是需要回收的,主要针对常量池回收,类型卸载。
- 常量池用于存放编译期生成的各种字节码和符号引用,常量池具有一定的动态性,里面可以存放编译期生成的常量;运行期间的常量也可以添加进入常量池中,比如
string
的intern()
方法。 - 当常量池无法再申请到内存时会抛出
OutOfMemoryError
异常。
直接内存
- 全称:Direct Memory
- **注意:**直接内存不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。
- 在JDK1.4中新加入的
NIO
(New Input/Output)类,引入了一种基于通道(Channel
)与缓冲区(Buffer
)的I/O方式,它可以使用Native
函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer
对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能能,因为避免了在Java堆和Native堆中来回复制数据。
对象访问
对象访问会涉及到Java栈、Java堆、方法区这三个最重要的内存区域之间的关联关系。
如下面这句代码:
Object objectRef = new Object();
假设这句代码出现在方法体中,Object objectRef
这部分将会反映到Java栈的本地变量中,作为一个reference
类型数据出现。而new Object()
这部分将会反映到Java堆中,形成一块存储Object类型所有实例数据值的结构化内存,根据具体类型以及虚拟机实现的对象内存布局(Object Memory Layout
)的不同,这块内存的长度是不固定。另外,在java堆中还必须包括能查找到此对象类型数据(如对象类型、父类、实现的接口、方法等)的地址信息,这些数据类型存储在方法区中。
reference
类型在java虚拟机规范里面只规定了一个指向对象的引用地址,并没有定义这个引用应该通过那种方式去定位,访问到java堆中的对象位置,因此不同的虚拟机实现的访问方式可能不同,主流的方式有两种:使用句柄和直接指针。
指针访问方式:reference
变量中直接存储的就是对象的地址,而java堆对象一部分存储了对象实例数据,另外一部分存储了对象类型数据。
垃圾收集算法
算法 | 说明 |
---|---|
复制算法(Copy) | 复制算法是所有算法里面效率最高的,缺点是会造成移动的空间浪费。 |
标记-清除算法(Mark-Sweep) | 效率一般,缺点是会造成内存碎片问题。 |
标记-整理算法(Mark-Compact) | 效率比前两者差,但是没有空间浪费,也消除了内存碎片的问题。 |
JVM是计算节点,而不是存储节点。研究表明,大部分的对象,可以分为两类:
- 大部分的生命周期都很短
- 其他对象则可能会存活很长一段时间
大部分死得快,其他活得长。这个假设我们称之为弱代假设(Weak Generational Hypothesis)。
- TLAB:Thread Local Allocation Buffer
JVM默认会给每个线程开辟一个Buffer区域,用于加速对象分配,这个Buffer就放在Eden区域中。
跟踪收集器
跟踪收集器采用的为集中式的管理方式,全局记录对象之间的引用状态,执行时从一些列GC Roots
的对象做为起点,从这些节点向下开始进行搜索所有的引用链,当一个对象到GC Roots
没有任何引用链时,则证明此对象是不可用的。
下图中,对象Object6
、Object7
、Object8
虽然互相引用,但他们的GC Roots
是不可到达的,所以它们将会被判定为是可回收的对象。
可作为GC Roots
的对象包括:
- 虚拟机栈(栈帧中的本地变量表)中的引用对象;
- 方法区中的类静态属性引用的对象;
- 方法区中的常量引用的对象;
- 本地方法栈中JNI的引用对象。
- 主要有复制、标记清除、标记压缩三种实现算法。
标记-清除算法
标记清除算法是最基础的收集算法,其他收集算法都是基于这种思想。标记清除算法分为“标记”和“清除”两个阶段:首先标记出需要回收的对象,标记完成之后统一清除对象。
它的主要缺点:
①.标记和清除过程效率不高
②.标记清除之后会产生大量不连续的内存碎片。
标记-整理算法
标记操作和“标记-清除”算法一致,后续操作不只是直接清理对象,而是在清理无用对象完成后让所有存活的对象都向一端移动,并更新引用其对象的指针。
主要缺点:在标记-清除的基础上还需进行对象的移动,成本相对较高,好处则是不会产生内存碎片。
老年代一般使用“标记-清除”,“标记-整理”算法,因为老年代存活率一般都比较高,空间又比较大,拷贝起来并不划算,还不如采取就地收集的方式。
复制算法
它将可用内存容量划分为大小相等的两块,每次只使用其中的一块。当这一块用完之后,就将还存活的对象复制到另外一块上面,然后在把已使用过的内存空间一次清理掉。这样使得每次都是对其中的一块进行内存回收,不会产生碎片等情况,只要移动堆订的指针,按顺序分配内存即可,实现简单,运行高效。
主要缺点:内存缩小为原来的一半。
引用计数收集器
引用计数收集器采用的是分散式管理方式,通过计数器记录对象是否被引用。当计数器为0时说明此对象不在被使用,可以被回收。
主要缺点:循环引用的场景下无法实现回收,例如下面的图中,ObjectC和ObjectB相互引用,那么ObjectA即便释放了对ObjectC、ObjectB的引用,也无法回收。SunJDK在实现GC时未采用这种方式。
分代收集算法
- 全称:Generational Collection
- 根据对象的存活周期的不同将内存划分为几块。一般是把Java堆划分为新生代和老年代。
- 在新生代中,每次垃圾收集的时候都发现有大批量的对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
- 在老年代中,因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。
附录
- 领沃实验室成员——Notzuonotdied
- 《
深入理解Java虚拟机-JVM高级特性与最佳实践
》 - 《
深入理解Android虚拟机
》 - JVM内存模型及分区
- Java虚拟机学习 - 对象访问
- Java虚拟机学习 - 垃圾收集算法