文章目录
众所周知,Java因其拥有独特的虚拟机(
JVM
)设计,使其成为一门跨平台、内存自动管理的高级开发语言。所谓跨平台
,即"一次编译,多次运行",从而解决了不同平台由于编译器不同导致无法运行问题;所谓内存自动管理
,即Java不像C/C++那样需要开发者来分配、释放内存,它拥有一套垃圾回收机制来管理内存,这套机制减轻了很多潜在的内存回收不当问题。然而,虽然Java的垃圾回收机制非常优秀,但当我们在写程序过程中有一些不好的习惯可能会导致部分内存无法被垃圾回收器回收,而我们自己又无法进行回收,从而导致这部分内存长期被占用无法释放,并且随着这部分内存的增大,极大的影响了程序的性能,这种情况被称之为“内存泄漏
”。
1. Java虚拟机(JVM)
虚拟机
是一种虚构出来的抽象化计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的,它拥有自己完善的虚拟硬件架构,如处理器、堆栈、寄存器等,而且还具有相应的指令系统。Java虚拟机就是这么一种虚拟机。Java虚拟机,即Java Virtual Machine(JVM)
,是运行所有Java程序的抽象计算机,是Java语言的运行环境,它屏蔽了与具体操作系统平台相关的信息,使得Java程序只需生成在Java虚拟机上运行的目标代码(字节码
)。任何平台只要装有针对于该平台的Java虚拟机,字节码文件(.class
)就可以在该平台上运行,即"一次编译,多次运行"
,正是因为如此,从而使得Java语言具有跨平台
移植的特性。
Java虚拟机本质上就是一个程序,Java程序的运行依靠具体的Java虚拟机实例,对于JVM来说,在其宿主OS的内存中只运行着一个JVM的实例,这个JVM实例中可以运行多个Java应用程序(进程),但是一旦JVM异常崩溃,就会导致运行在其中的所有程序被关闭。。在Java虚拟机规范中,JVM主要包括五大模块,即类装载器子系统
、运行时数据区
、执行引擎、本地方法接口和垃圾收集
模块。其中,类加载器子系统,用于加载字节码文件到内存,就是JVM中的runtime data area(运行时数据区)的method area方法区,整个过程中装载器只负责文件结构格式能够被装入,并不负责能不能运行;运行时存储区,即JVM内存区域,JVM运行程序的时候使用;执行引擎,在不同的虚拟机实现里面,执行执行引擎可能会有解释器
解释执行字节码文件或即时编译器编译
产生本地代码执行字节码文件,可能两种都有;本地方法接口,即Java Native Interface(JNI)
,用于与本地库(native library)交互,是Java与其他编程语言(C/C++)交互的"桥梁";垃圾收集,用于对已分配的内存资源进行回收,主要是Java堆和方法区的内存。JVM架构如下图所示:
1.1 JVM内存管理
Java虚拟机在执行Java程序时会把它所管理的内存划分若干个不同的数据区域,这些区域的用途各不相同,创建、销毁的时间也各有区别,比如有的随着Java虚拟机进程
的启动而存在、有的区域则依赖于用户线程
的启动和结束而创建、销毁,但它们有一个共同的“名字”,即运行时数据区域
。Java虚拟机管理的内存主要有:程序计数器
、Java虚拟机栈
、本地方法栈
、Java堆
、方法区
以及直接内存
等,其中,程序计数器、虚拟机栈和本地方法栈为线程私有,方法区和Java堆为线程间共享。
- 程序计数器
程序计数器(Program Counter Register)是内存中的一块较小的区域,它可以看作成是当前线程
所执行的字节码行号指示器,依赖于用户线程
的启动和结束而创建、销毁,是线程私有
内存数据区域。由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一确定的时刻,一个处理器都只会执行一条线程中的指令,因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各个线程之间计数器互不影响、独立存储。需要注意的是,如果线程正在执行的是一个Java方法
,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果线程正在执行的是Native方法
,那么这个计数器的值为空。
在Java虚拟机规范中,程序计数器是唯一一个没有规定任何
OutOfMemoryError
情况的区域。
- Java虚拟机栈
类似于程序计数器,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有
,生命周期与用户线程周期相同,它描述的是Java方法执行的内存模型,即每个Java方法
在执行时JVM会为其在这部分内存中创建一个栈帧(Stack Frame)
用于存储局部变量表
、操作数栈
、动态链接
以及方法出口信息
等,每一个方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈和出栈过程。局部变量表是我们在开发过程中接触较多的部分,它存放了编译器可知
的各种基本数据类型(byte/boolean/char/int/short/long/float/double)
、对象引用(reference类型)
和returnAddress类型
,其中,64位长度的long和double类型的数据占用2个局部变量空间,其他的类型占1个(4个字节
)。局部变量表所需的内存空间在编译期
间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完成确定的,在方法运行期间不会改变局部变量表的大小
。下图是虚拟机栈存储示意图:
在Java虚拟机规范中,虚拟机栈可能会出现两种异常情况,即
StackOverflowError
和OufOfMemoryError
,其中,StackOverflowError出现在如果线程请求的栈深度大于虚拟机所允许的深度
;OufOfMemoryError出现在如果虚拟机栈可以动态扩展,但是扩展后仍然无法申请到足够的内存
。
- 本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用非常相似,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(即字节码
),而本地方法栈则为虚拟机使用到的Native方法
服务。下图演示了一个线程调用Java方法和本地方法时的栈,以及虚拟机栈和本地方法栈之间毫无障碍的跳转。示意图如下:
在Java虚拟机规范中,本地方法栈也会出现
StackOverflowError
和OufOfMemoryError
异常情况。
- Java堆
Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块,它在JVM启动
时被创建,生命周期与JVM相同,是被所有线程所共享
的一块区域,此区域唯一的目的是存放对象实例
和数组
,几乎所有的对象实例都在这里分配内存。Java堆是垃圾收集器管理的主要区域,也是"内存泄漏
"集中出现的地方。由于JVM中的垃圾收集器大部分采用分代收集算法,因此,Java堆又被细分为:新生代和老年代,其中,新生代
区域存放创建不久的对象,老年代
存放经历过多次GC后仍然存活的对象。实际上,根据JVM规范,Java堆还可被继续细分为Eden
空间、From Survivor空间
以及To Survivor空间
等,这个我们在垃圾回收机制模块详细阐述。下图是Java堆内存划分示意图:
在JVM规范中,如果堆可以动态扩展,但是扩展后仍然无法申请到足够的内存,就会抛出OutOfMemoryError异常。当然,我们可以通过
-Xmx
和-Xms
来控制堆内存的大小,其中,-Xmx
用于设置Java堆起始的大小,-Xms
用于设置Java堆可扩展到最大值。
- 方法区
像Java堆一样,方法区(Method Area)是各个线程共享
的内存区域,它的生命周期与虚拟机相同,即随着虚拟机的启动和结束而创建、销毁。方法主要用于存放已被虚拟机加载的类信息、常量、静态变量以及即时编译器(JIT)编译后的代码
等数据,它的大小决定了系统能够加载多少个类,如果定义的类太多,导致方法区抛出OutOfMemoryError异常。需要注意的是,对于JDK1.7来说,在HotSpot虚拟机中方法区可被理解为"永久区",但是JDK1.8以后,方法区已被取消,替代的是元数据区
。元数据区是一块堆外的直接内存,与永久区不同,如果不指定大小,默认情况下在类加载时虚拟机会尽可能加载更多的类,直至系统内存被消耗殆尽。当然,我们可以使用参数-XX:MaxMetaspaceSzie
来指定元数据区的大小。
运行时常量池用于存放编译期生成的各种字面量和符号引用。
- 直接内存
直接内存(Direct Memory)不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,它是在JDK1.4中新加入的NIO(New Input/Output)类,通过引入了一种基于通道与缓冲区的I/O方式,使用Native函数库直接分配得到的堆外内存。对于这部分内存区域,主要通过存储在Java堆中的DirectByteBuffer
对象作为这块内存的引用进行操作,直接内存的存在避免了在Java堆和Native堆中来回复制数据,从而在某些场景能够显著地提高性能。
本机直接内存的分配不会受到Java堆大小的限制,但是仍然会受到本机总内存(
包括RAM以及SWAP或者分页文件
)大小以及处理器寻址空间的限制。如果申请分配的内存总和(包括直接内存)超过了物理内存的限制,就会导致动态扩展时出现OutOfMemoryError异常。
1.2 垃圾回收器与内存分配策略
在上一节中我们详细分析了JVM的运行时内存区域,了解到程序计数器、虚拟机栈、本地方法栈是线程的私有区域,当线程结束时这部分所占内存资源将会自动释放,而线程的共享区域Java堆
是存放所有对象实体的地方,因此是垃圾回收器回收(GC,Garbage Collection
)的主要区域。(方法区也会有GC,但是一般我们讨论的是Java堆)
1.2.1 如何判断对象"已死"?
垃圾收集器在回收一个对象,第一件事就是确定这些对象之中有哪些是“存活”的,哪些已经“死亡”,而垃圾回收器回收的就是那些已经“死亡”的对象。如何确定对象是否已经死亡呢?通常,我们可能会说当一个对象没被任何地方引用时,就认为该对象已死。但是,这种表述貌似不够准确,因此,JVM规范中给出了两种判断对象是否死亡的方法,即引用计数法
和可达性分析
。
- 引用计数法
引用计数法实现比较简单,它的实现原理:给对象一个引用计数器,每当一个地方引用它时,计数器就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就会被认为已经死亡
。客观地说,引用计数器法效率确实比较高,也容易实现,但是它也有不足之处,就是无用对象之间相互引用
的问题,这种情况的出现会导致相互引用的对象均无法被垃圾回收器回收。
- 可达性分析
为了解决引用计数法
的无用对象循环引用导致无法被回收情况,JVM中又引入了可达性分析
算法来判断对象是否存活,这种算法也是普遍被应用的方式。可达性分析基本思想:通过一系列被称为"GC Roots
"的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链
。当一个对象到GC Roots
没有任何引用链
相连接时,则证明此对象不可用,即被判断可回收对象。可达性分析算法示意图如下图所示:
那么,哪些对象可以作为"GC Roots"呢?
- 虚拟机栈中(
局部变量表
)引用的对象; - 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中Native方法引用的对象;
1.2.2 垃圾收集算法
前面我们通过引用计数法
或可达性分析
找到了哪些对象是可以被回收的,本节将重点阐述JVM中的垃圾回收器是如何将这些不可用对象进行回收,即垃圾收集算法,主要包括标记-清除算法
、复制算法
、标记-整理
以及分代收集
等。相关介绍如下:
- 标记-清除算法
标记-清理算法是最基础的垃圾收集算法,它的实现分为两个阶段,即“标记”
和“清除”
,其中,标记的作用为通过引用计数法或可达性分析算法标记出所有需要回收的对象;清除的作用为在标记完成后统一回收所有被标记的对象。这种算法比较简单,但是缺陷也比较明显,主要表现为两个方面:一是标记和清理的效率比较低;二是标记清理之后会产生大量不连续的内存碎片,空间碎片太大可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不触发另一次GC。
标记-清除算法执行过程如下图所示:
- 复制算法
为了解决标记-清理算法效率不高问题,人们提出了一种复制算法
,它的基本原理:将可用内存容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活的对象复制到另一块上,然后再把已使用的内存空间一次性清理掉。
这种方式实现简单,运行高效,且缓解了内存碎片的问题,但是由于其只对整个半区进行内存分配、回收,从而导致可使用的内存缩小为整个内存的一半。复制算法执行过程如下图所示:
在HotSpot虚拟机中,整个内存空间被分为一块较大的
Eden
空间和两块较小的Survivor
空间,每次使用Eden