对于 Java 程序员来说,在虚拟机自动内存管理机制下,不再需要像C/C++程序开发程序员这样为内一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏和内存溢出问题。
正是因为 Java 程序员把内存控制权利交给 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务。
1. Java内存区域(运行时数据区域)
Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。
Java虚拟机所管理的内存包括以下几个运行时数据区域。
JDK1.6的运行时数据区域划分如下:
JDK1.8的运行时数据区域划分如下:
Java内存运行区域中,有些组成部分是线程私有的,其他的则是线程共享的。
线程私有的:
- 程序计数器
- 虚拟机栈
- 本地方法栈
线程共享的:
- 堆
- 方法区
- 直接内存
2. 程序计数器(PC Register)
程序计数器是一块较小的内存空间,小到可以忽略不计,也是运行速度最快的存储区域。
可以看做是当前线程所执行的字节码的行号指示器。
字节码解释器工作时通过改变这个程序计数器的值来选取下一条需要执行的字节码指令,比如分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。
此外,为了线程切换后能恢复到正确的位置,每条线程都需要有一个独立的程序计数器,各个线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
在JVM中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。它是唯一一个在Java虚拟机规范中没有规定任何OutMemoryError情况的区域。
总结上边内容,程序计数器主要有两个作用:
- PC寄存器用来存储指向下一条指令的地址,字节码解释器通过改变程序计数器的值来依次读取指令,从而实现代码的流程控制。如:顺序执行、选择、循环、异常处理、跳转、线程恢复等。
- 在多线程情况下,程序计数器用来记录当前线程执行的位置,从而当线程被切换回来时能够知道该线程上次执行到哪儿。
3. Java虚拟机栈(Java Virtual Machine Stack)
Java虚拟机栈是什么
Java虚拟机栈(Java virtual Machine Stack),早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的Java方法调用。与程序计数器一样,Java虚拟机栈也是线程私有的。
- 生命周期
Java虚拟机栈的生命周期和线程相同,描述的是Java方法执行的内存模型。
- 作用
Java内存可以粗糙的区分为堆(Heap)内存和栈(Stack)内存。
主管Java程序的运行,它保存方法的局部变量表、部分结果、并参与方法的调用和返回。。
(实际上,Java虚拟机栈是由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法出口信息)。
局部变量表主要存放了编译器可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他于此对象相关的位置)。
Java虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。
Java虚拟机规范允许栈的大小是动态或者固定不变的。
- StackOverFlowError:若Java虚拟机栈的内存大小采用固定的不允许动态扩展,那每一个线程的Java虚拟机容量可以在线程创建的时候独立选定,如果线程请求分配的栈容量超过当前Java虚拟机栈的最大容量的时候,就抛出StackOverFlowError异常。
- OutOfMemoryError:若Java 虚拟机栈的内存大小允许动态扩展,且当线程在内存不足时尝试动态扩展却没有申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,无法再动态扩展了,此时抛出OutOfMemoryError异常。
Java虚拟机栈是线程私有的,每个线程都有各自的Java虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡。
栈的特点?
- 栈是一种快速有效的分配存储方式,访问速度仅次于线程计数器。
- JVM直接对Java栈的操作只有两个:
【1】每个方法执行,伴随着进栈(入栈、压栈)
【2】执行结束后的出栈工作
- 对于栈来说不存在垃圾回收问题
- 每个线程都有自己的栈,栈中的数据都是以栈帧(Stack Frame)的格式存在。
- 在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame)。
在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧是有效的,这个栈帧被称为当前栈帧,与当前栈帧相对应的方法就是当前方法,定义这个方法的类就是当前类。如下图所示,当前栈帧4,对应的执行方法4
- 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。
那么方法/函数如何调用?
Java 栈可以类比数据结构中栈,Java 栈中保存的主要内容是栈帧,每一次函数调用都会有一个对应的栈帧被压入 Java 栈,每一个函数调用结束后,都会有一个栈帧被弹出。
Java 方法有两种返回方式:
- return 语句。
- 抛出异常。
不管哪种返回方式都会导致栈帧被弹出。
4. 本地方法栈(Native Method Stack)
本地方法栈和虚拟机栈发挥的作用相似。
本地方法栈和Java虚拟机栈的区别:
- 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务。
- 本地方法栈则为虚拟机使用到的 Native 方法服务。
在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、方法出口信息。
方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种异常。
5. 堆(Heap)
堆内存是Java虚拟机所管理的内存中最大的一块,Java堆是所有线程共享的一块内存区域,在虚拟机启动时创建。
堆内存的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
Java堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap)。
从垃圾回收角度,由于现在收集器基本都采用分代垃圾回收算法,所以,
Java堆还可以细分为:新生代、老年代。
再细分有:Eden空间、From Survivor、To Survivor空间等。
进一步划分的目的是更好地回收内存,或者更快地分配内存。
在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:
- 新生代内存(Young Generation)
- 老生代(Old Generation)
- 永生代(Permanent Generation)
在 JDK 1.8中移除整个永久代,取而代之的是一个叫元空间(Metaspace)的区域(永久代使用的是JVM的堆内存空间,而元空间使用的是物理内存,直接受到本机的物理内存限制)。
上图所示的 Eden 区、两个 Survivor 区都属于新生代(为了区分,这两个 Survivor 区域按照顺序被命名为 from 和 to),中间一层属于老年代。
大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold来设置。
堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:
-
java.lang.OutOfMemoryError: GC Overhead Limit Exceeded
-
java.lang.OutOfMemoryError: Java heap space
:假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx
参数配置,若没有特别配置,将会使用默认值。
6. 方法区(Method Area)
方法区与 Java 堆一样,是各个线程共享的内存区域。
方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
HotSpot 虚拟机中方法区也常被称为 “永久代”,本质上两者并不等价。仅仅是因为 HotSpot 虚拟机设计团队用永久代来实现方法区而已,这样 HotSpot 虚拟机的垃圾收集器就可以像管理 Java 堆一样管理这部分内存了。但是这并不是一个好主意,因为这样更容易遇到内存溢出问题。
相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。
方法区的内存回收目标主要是针对常量池的回收和对类型的卸载。如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryEr异常。
7. 运行时常量池(Runtime Constant Pool)
运行时常量池是方法区的一部分。
Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池表(Constant Pool Table),于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后放到方法区的运行时常量池中。
当常量池无法再申请到足够内存时会抛出OutOfMemoryErr异常。
8. 直接内存(Direct Memory)
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 异常出现。
JDK1.4中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel) 与缓存区(Buffer) 的 I/O 方式,它可以直接使用Native函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。
本机直接内存的分配不会收到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。