Java内存模型在1.8之前和1.8之后略有不同,也就是运行时数据区域,请看如下图:
运行时数据区域
Java1.6:
JDK1.8
正如上图所示:Java内存模型可以简要分为两种:
线程私有的:
- 虚拟机栈
- 本地方法栈-Native Method Stack
- 程序计数器-Program Counter Register
线程共享的:
- 堆-Heap
堆可以是连续空间,也可以不是连续空间,同时也可以固定大小,也可以在运行时扩展;并且虚拟机的实现者可使用任何的垃圾回收算法管理堆,甚至完全不进行垃圾收集也是可以的
- 方法区-Method Area
- 直接内存-Direct Memory
程序计数器
程序计数器是一个较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令;分支、循环、跳转、异常处理、线程恢复等功能都需要这个计数器来协助完成。
还有就是为了线程切换后能恢复到正确的位置,每个线程都有自己的独立程序计数器,各个线程之间程序计数器互不影响,独立存储。所以我们称这类内存区域为线程私有的内存。
综上所述:
程序计数器主要有两大作用:
- 字节码解析器通过改变程序计数器来依次执行指令,从而实现代码的流程控制,如:顺序执行、选择、跳转、异常处理等等
- 在多线程情况先,每个线程拥有自己独立的程序计数器,并由程序计数器记录当前线程执行的位置,从而可以使当前线程切换回来后可以知道上次运行的位置
TIP:
程序计数器是唯一不会出现OutOfMemoryError的内存区域,它的生命周期随着线程的创建而创建,随着线程的消亡而消亡
Java虚拟机栈
Java虚拟机栈和程序计数器一样也是线程私有的,Java虚拟机栈也可称为栈、Java栈,同样是随着线程的创建二创建随着线程的消亡而消亡。
Java栈可以称得上是JVM运行时数据区域的一个核心。因为除了一些Native方法是通过本地方法栈实现的,其它的所有Java方法都是通过Java栈来实现的。但是也是需要其它的运行时内存区域的配合比如程序计数器。
通过方法调用的数据都需要通过Java栈来进行传递,每一次方法调用都会有一个对应的栈帧压入栈中,每一个方法调用结束后都有一个栈帧弹出。
栈的组成
每一个栈都是由一个个栈帧组成,栈帧里又拥有局部变量表、操作数栈、动态链接、方法返回地址。它的结构和我们学习的数据结构中的栈比较类似,都是先进后出,只支持入栈和出栈
局部变量表:主要存放编译器各种可知的各种数据类型(boolean、float、int、double、byte、char、short、long)、对象引用(reference,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其它与此对象相关的位置)
操作数栈:主要是作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果,另外计算过程中产生的临时变量也会存在操作数栈中
动态链接:主要是服务一个方法需要调用其它方法,在Java源文件编译成字节码文件时,所有的方法和变量都作为符号引用 (Symbilic Reference) 保存在Class文件的常量池中,当一个方法调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接就是为了将符号引用转化为调用方法的直接引用。
栈的空间虽然是无限的,但是一般正常情况下调用时没有问题的。不过如果函数陷入了无限循环的话,就会导致被压入太多栈帧而导致占用太多空间,从而导致栈空间过深。如果当前请求的栈深度超过当前Java虚拟机的最大深度的时候就会抛出StackOverFlowError
Tip:
Java方法有两种返回方式:
- 正常的return返回
- 抛出异常
以上不管哪种放回方式都会导致栈帧弹出。也就是说栈帧随着方法的调用而创建,随着方法的结束而销毁,无论是正常完成还是异常完成都算方法结束
除了上述提到的StackOverFlowError错误之外,栈还有可能发生OutOfMemoryError 错误,这是因为栈的内存大小可以动态扩展,如果虚拟机在动态扩展时却无法申请到足够的内存空间,则会抛出OutOfMemoryError的异常
综上所述:
栈可能会出现两种错误
- StackOverflowError:若栈的内存空间不允许动态扩展,那么当前线程请求栈的深度如果超过Java虚拟机栈的最大深度,则会抛出StackOverflow的错误
- OutOfMemoryError:如果栈的内存大小可以动态扩展,如果Java虚拟机栈在动态扩展内存时无法申请到足够的内存空间,则会抛出OutOfMemoryError的错误
参考书籍:《深入理解Java虚拟机》-第三版
本地方法栈
和虚拟机栈所发挥的作用非常类似,区别是:虚拟机栈为虚拟机执行Java方法服务(也就是字节码服务),而本地方法栈则为虚拟机使用到的Native方法服务。在HotSpot虚拟机中和Java虚拟机栈合二为一。
本地方法栈执行的时候,在本地方法栈也会创建一个栈帧,用于存放本地方法的局部变量表,操作数栈、动态链接、方法返回地址。方法执行完毕后相应的栈帧也会弹出并释放内存空间,同时也会出现StackOverflowError和OutOfMemoryError两种错误
堆
Java虚拟机所管理的内存中最大的一块,Java堆使所有线程共享的一块内存区域,在Java虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例和数组都在这里分配内存。
Java世界中几乎所有的对象都在堆中分配,但是随着JIT编译器的发展和逃逸分析技术的逐渐成熟,栈上分配、标量替换优化技术会导致一些微妙的变化,所有的对象都分配到栈上也没有那么绝对了。从JDK1.7开始就已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
Java堆是垃圾回收器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap)。从垃圾回收的角度:由于现在的收集器基本都是采用分代垃圾收集算法,所以Java堆还可细分为:新生代和老年代;再细致一点还有Eden、Survivor、Old等空间,进一步划分的目的是更好的回收内存,或者说是更快的回收内存。
在JDK版本1.7和JDK版本1.7之前堆主要分为:
- 新生代内存(Young Generation)
- 老年代(Old Generation)
- 永久代(Permanent Generation)
具体如图所示(图中的Eden区、两个Survivor区 S0、S1)都属于新生代,中间一层属于老年代,最下面一层属于永久代。
JDK 8 版本后就移除了PermGen(永久)使用MetaSpace(元空间)所替代。元空间使用的是直接内存。
关于JVM是如何动态计算年龄的大致如下:
Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累计,当累计的某个年龄大小超过Survivor区的一半时,取这个年龄和MaxTenuringThreshold中更小的一个值,作为新的晋升阈年龄值
uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
//survivor_capacity是survivor空间的大小
size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);
size_t total = 0;
uint age = 1;
while (age < table_size) {
total += sizes[age];//sizes数组是每个年龄段对象大小
if (total > desired_survivor_size) break;
age++;
}
uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
...
}
堆这里最容易出现的错误是OutOfMemoryError,并且出现这种错误的表现形式还有几种比如:
- java.lang.OutOfMemoryError: GC Overhead Limit Exceeded,当JVM花费太多时间来执行垃圾回收,并且只能回收很小的堆空间时,就会发生此错误
- java.lang.OutOfMemoryError: Java heap space:假如在创建新对象时,堆的内存空间不足以存放该新对象时,就会发生次错误。(和配置的最大栈内存有关,并且受制于物理内存的大小,最大堆内存可通过参数-Xmx配置,若没有特别配置,则使用默认的配置),这个默认值目前我本人并没有在哪本书籍上看到,或者是我忘记了。可参考文章:默认的堆大小
- 还有很多类似的表现就不以一举例了
方法区
方法区是JVM运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。
《Java虚拟机规范》只是规定了方法区的概念和它的作用,方法区是如何实现的就要看Java虚拟机它自己的实现了,换句话说就是在不同的Java虚拟机上,方法区的实现方式是有可能不同的。
当虚拟机要使用一个类的时候,它需要读取并解析Class文件获取的相关信息,再将信息存入方法区。方法区会存储已被虚拟机加载的类信息、字段信息、方法信息、常量、静态变量、即使编译器编译后的代码缓存等数据。
那么问题来了方法区和永久代、元空间有什么关系呢?
其实三者之间的关系很像Java类中的接口和实现类,类实现接口;方法区比较像接口,而永久代和元空间更像是方法区的具体实现(这里指的是Hotspot虚拟机对方法区的两种实现方式)。并且永久代是JDK1.8之前的实现方式,JDK1.8之后由元空间所代替。
至于为什么永久代(PermGen)会被元空间(MetaSpace)所替代呢?在《深入理解Java虚拟机中》3版有下面这段话:
- 关于永久代有一个JVM本身设置的固定大小上限,它是无法进行调整的;但是元空间使用的是直接内存,这意味着元空间只受物理内存空间大小的限制,即使它也有可能会出现内存溢出的情况,但是几率要相对小很多
- 元空间内存溢出会出现如下错误:
java.lang.OutOfMemoryError: MetaSpace
元空间的大小
- 关于元空间的大小可以通过参数-XX: MetaSpaceSize 来设置元空间的最大限制,默认是unlimited意味着只受系统内存空间的限制;-XX: MetaSpaceSize参数 定义了元空间的初始大小,如果未指定该参数,则元空间(MetaSpace)则会在运行时的应用程序动态调整大小。
元空间存储的数据
- 元空间存放的是类的元数据,如果未指定参数 --XX: MetaSpaceSize的大小,那么加载多少类的元数据就不由参数MetaSpaceSize来控制了,就由系统实际可用的内存空间来限制了,其实这样能够加载类的元数据相比较会更多一些。
- 在JDK 8,合并Hotspot和JRockit的代码时,JRockit压根也没有一个永久代的概念,合并之后就没必要额外的设置一个永久代的地方了,
方法区常用的参数
- JDK1.8 之前永久代还没有移除的时候通常通过以下参数来进行调解:
# 方法区 永久代的初始大小
-XX:PermSize=N
# 方法区 永久代的最大大小 ,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen
-XX:MaxPermSize=N
相对而言垃圾的收集行为在该区域是比较少出现的,但是并不意味着数据进入方法区就永久存在了。
JDK1.8之后,永久代就被移除了(其实在JDK1.7就已经开始了),取而代之的是元空间。
- 元空间参数调节:
# 设置元空间(MetaSpace)的初始大小(和最小的大小)
-XX:MetaSpaceSize:N
# 设置元空间(MetaSpace)的最大的大小
-XX:MaxMetaSpaceSize:N
元空间与永久代不同就是在于:如果不指定大小的话,随着创建的类越来越多,最后可能后导致系统内存的耗尽。
运行时常量池
Class文件除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译期生成的各种字面量、符号引用的常量池表
字面量在源码中是固定值的表示法,简单来说就是通过字面量我们就知道其值的含义。字面量主要包括整数、浮点数和字符串字面量,符号引用包括类符号引用、字段符号引用、方法符号引用和接口方法符号引用。
常量池会在类加载后存放到方法区的运行时区常量池。
运行时常量池的功能类似于传统编程语言的符号表,尽管它包含了比常规的符号表更宽泛的数据
既然运行时常量池是方法区的一部分,自然而然也收到内存的限制,当常量池无法申请到内存时也会抛出OutOfMemoryError的错误。
字符串常量池
字符串常量池是JVM为了提高性能减少内存消耗专门为字符串(String类)开辟的一块内存区域,主要目的是为了防止字符串的重复创建。
// 在堆中创建字符串对象"paidaxing"
// 将字符串对象"paidaxing"的引用保存在字符串常量池中
String a = "paidaxing";
// 直接返回字符串常量池中字符串对象"paidaxing"的引用
String b = "paidaxing";
System.out.println(a==b);// true
Hotspot常量池的具体实现是:src/hotspot/share/classfile/stringTable.cpp,StringTable实际上就是一个HashSet,容量为StringTableSize。可以通过参数-XX:StringTableSize来设置。
StringTable保存的是字符串对象的引用,字符串的引用指向堆中的字符串的对象。
说到字符串那么就会有一个面试题,字符串是保存在哪里的?先说总结:
在JDK1.7之前字符串常量池是保存在永久代的,JDK1.7及1.7之后字符串常量池和静态变量是保存在Java堆中的。
如图所示:
问题来了为什么JDK1.7要将字符串常量池移到堆中呢?
主要原因就是永久代(PermGen)-【方法区的实现】的GC的效率太低了,只有在整堆收集(也就是Full GC)的时候才会被执行GC,Java通常情况下会有大量被创建的字符串需要被回收,将字符串常量池存放到堆中,能够提高GC的回收效率,及时回收字符串的内存。
比较好的问题:
直接内存
直接内存并不是虚拟机运行时数据区域的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁的使用。也有可能导致OutOfMemoryError的错误。
在Java1.4中新加入的NIO(New Input/Output类),引入了基于通道(Channel)和缓存区(Buffer)的I/O方式。它可以直接使用Native函数直接分配堆外内存,然后通过Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样在一些场景中就能显著的提高性能,避免了Java堆和Native堆之间来回复制数据。
本机直接内存的分配不会受到Java堆的限制,但是既然是内存总会收到系统本机内存以及处理器寻址空间的限制。