1.JVM内存结构
上图是Java8之前,Java8使用元空间替代方法区
程序计数器
概述:较小的内存空间,为当前线程执行的字节码的行号指示器
作用:通过改变计数器的值来指定下一条需要执行的字节码指令,来恢复中断前程序运行的位置
特点:
- 线程私有化,每个线程都有独立的程序计数器。
- 唯一一个在Java虚拟机中无内存溢出的区域。
Java虚拟机栈
概述:虚拟机栈描述的是Java方法执行的内存模型。每个方法从调用直到执行的过程,对应着一个栈帧在虚拟机栈的入栈和出栈的过程
作用:每个方法执行都创建一个“栈帧”来存储局部变量表、操作数栈、动态链接、方法出口等信息。其中局部变量表存放了编译器可知的各种基本类型(boolean byte char short int float long double)、对象引用(reference类型,他不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节吗指令的地址)。
特点:
- 线程私有化
- 生命周期与线程执行结束相同
可以通过 -Xss 这个虚拟机参数来指定每个线程的 Java 虚拟机栈内存大小:
java -Xss512M HackTheJava
该区域可能抛出以下异常:
当线程请求的栈深度超过最大值,会抛出 StackOverflowError 异常;
栈进行动态扩展时如果无法申请到足够内存,会抛出 OutOfMemoryError 异常。
本地方法栈
本地方法栈与 Java 虚拟机栈类似,它们之间的区别是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。
本地方法一般是用其它语言(C、C++ 或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序,对待这些方法需要特别处理。
与虚拟机栈一样,本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。
方法区的大小设置:
-XX:PermSize=N //⽅法区 (永久代) 初始⼤⼩
-XX:MaxPermSize=N //⽅法区 (永久代) 最⼤⼤⼩,超过这个值将会抛出 OutOfMemoryError 异 常:java.lang.OutOfMemoryError: PermGen
相对⽽⾔,垃圾收集⾏为在这个区域是⽐少出现的,但并⾮数据进⼊⽅法区后就“永久存在”
了。
元空间
JDK 1.8 的时候,⽅法区(HotSpot 的永久代)被彻底移除了(JDK1.7 就已经开始了),取⽽代
之是元空间,元空间使⽤的是直接内存。
元空间的大小设置:
-XX:MetaspaceSize=N //设置 Metaspace 的初始(和最⼩⼤⼩)
-XX:MaxMetaspaceSize=N //设置 Metaspace 的最⼤⼤⼩
与永久代很⼤的不同就是,如果不指定⼤⼩的话,随着更多类的创建,虚拟机会耗尽所有可⽤的
系统内存。
为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
- 整个永久代有⼀个 JVM 本身设置固定⼤⼩上限,⽆法进⾏调整,⽽元空间使⽤的是直接内
存,受本机可⽤内存的限制,虽然元空间仍旧可能溢出,但是⽐原来出现的⼏率会更⼩。
当你元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace
你可以使⽤ -XX MaxMetaspaceSize 标志设置最⼤元空间⼤⼩,默认值为 unlimited,这意味着
它只受系统内存的限制。 -XX MetaspaceSize 调整标志定义元空间的初始⼤⼩如果未指定此标
志,则 Metaspace 将根据运⾏时的应⽤程序需求动态地重新调整⼤⼩。 - 元空间⾥⾯存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了,
⽽由系统的实际可⽤空间来控制,这样能加载的类就更多了。 - 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有⼀个叫永久代的东⻄, 合并
之后就没有必要额外的设置这么⼀个永久代的地⽅了。
堆
Java堆是Java虚拟机所管理的内存中最大的一块。他被所有线程共享的一块区域,在虚拟机启动时创建。此内存区域的唯一目的时存放对象实例,几乎所有的对象实例都在这里分配内存。
因而,堆是垃圾收集的主要区域(“GC 堆”)。
现代的垃圾收集器基本都是采用分代收集算法,其主要的思想是针对不同类型的对象采取不同的垃圾回收算法。可以将堆分成两块:
- 新生代(Young Generation)
- 老年代(Old Generation)
堆不需要连续内存,并且可以动态增加其内存,增加失败会抛出 OutOfMemoryError 异常。
可以通过 -Xms 和 -Xmx 这两个虚拟机参数来指定一个程序的堆内存大小,第一个参数设置初始值,第二个参数设置最大值。
java -Xms1M -Xmx2M HackTheJava
创建时间:JVM启动时创建该区域
占用空间:Java虚拟机管理内存最大的一块区域
作用:用于存放对象实例及数组(所有new的对象)
特点:
- 垃圾收集器作用该区域,回收不使用的对象的内存空间
- 各个线程共享的内存区域
- 该区域的大小可通过参数设置
方法区
作用:是各个线程共享的内存区域。它用于存储类信息、常量、静态变量、即时编译后的代码等数据。
对这块区域进行垃圾回收的主要目标是***对常量池的回收和对类的卸载***,但是一般比较难实现。
HotSpot 虚拟机把它当成永久代来进行垃圾回收。但很难确定永久代的大小,因为它受到很多因素影响,并且每次 Full GC 之后永久代的大小都会改变,所以经常会抛出 OutOfMemoryError 异常。为了更容易管理方法区,从 JDK 1.8 开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存中。
补充:⽅法区和永久代的关系
《Java 虚拟机规范》只是规定了有⽅法区这个概念和它的作⽤,并没有规定如何去实现它。那么,在不同的 JVM 上⽅法区的实现肯定是不同的了。 ⽅法区和永久代的关系很像Java 中接⼝和类的关系,类实现了接⼝,⽽永久代就是 HotSpot 虚拟机对虚拟机规范中⽅法区的⼀种实现⽅式。 也就是说,永久代是 HotSpot 的概念(元空间道理一样),⽅法区是 Java 虚拟机规范中的定义,是⼀种规范,⽽永久代是⼀种实现,⼀个是标准⼀个是实现,其他的虚拟机实现并没有永久代这⼀说法。
运行时常量池
(2020.1.5 更新)
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息时常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这些内容将在类加载后进入方法区的运行时常量池中存放。
既然运⾏时常量池是⽅法区的⼀部分,⾃然受到⽅法区内存的限制,当常量池⽆法再申请到内存时会抛出 OutOfMemoryError 错误。
补充:
1. JDK1.7之前运⾏时常量池逻辑包含字符串常量池存放在⽅法区, 此时hotspot虚拟机对⽅法区的实现为永久代
2. JDK1.7 字符串常量池被从⽅法区拿到了堆中, 这⾥没有提到运⾏时常量池,也就是说字符串常量池被单独拿到堆,运⾏时常量池剩下的东⻄还在⽅法区, 也就是hotspot中的永久代 。
3. JDK1.8 hotspot移除了永久代⽤元空间(Metaspace)取⽽代之, 这时候字符串常量池还在堆, 运⾏时常量池还在⽅法区, 只不过⽅法区的实现从永久代变成了元空间
(Metaspace)
2.Java对象模型
Java是一种面向对象的语言,而Java对象在JVM中的存储也是有一定的结构的。而这个关于Java对象自身的存储模型称之为Java对象模型。
每一个Java类,在被JVM加载的时候,JVM会给这个类创建一个instanceKlass,保存在方法区,用来在JVM层表示该Java类。当我们在Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了对象头以及实例数据。
注意:
1.JVM内存结构,和Java虚拟机的运行时区域有关。
2.Java对象模型,和Java对象在虚拟机中的表现形式有关。
对象的创建及内存分配
(2020.1.6 更新)
在面向对象的编程语言中,创建对象通常是简单的使用new关键字。但在JVM中,遇到一条new指令时,首先将去检查这个指令的参数是否在常量池中定位到类的符号引用,并且检查这个符号引用代表的类是否被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。
分配内存的方式:
- 指针碰撞(Bump the pointer):针对Java堆中内存是绝对规整的,用过和没用过的内存各放一边,中间一个指针作为分界点的指示器,这样分配内存就变成把那个指针指向空闲空间移动的问题。
- 空闲列表(Free List):针对Java堆中内存是不规整的,已使用的和未使用的内存相互交错,JVM维护一个列表,记录上哪些内存是可用的,在分配时从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
实际应用中,具体使用的是哪种内存分配方式有Java堆中的内存是否规整决定。在使用Serial、ParNew等Compact过程的垃圾收集器时,系统使用的方法是指针碰撞,而是用CMS这种基于Mark-Sweep算法的收集器时采用的是空闲列表。
除此之外,还需要考虑的问题是对象创建在虚拟机中是否是频繁的行为,这在并发下不是线程安全的。通常的解决方案有两种:
- 对分配内存空间的动作进行同步处理——实际上是JVM采用CAS配上失败重试的方法保证更新操作的原子性。
- 把分配内存的动作按照线程划分到不同的空间之中进行,也就是每个线程在Java堆中预先分配一块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。
对象的内存布局
在HotSpot虚拟机中,对象在内存中的存储的布局可以分为3块内存区域:对象头(Header)、实例数据(Instance Data)和对象填充(Padding)。
实例数据部分是对象真正存储的有效信息,也是在代码中多定义的各种类型的字段内容。
对象填充不是必然存在的,也没有特殊意义,仅仅起着占位符的作用。
3.垃圾回收(GC) 非常重要!
在Java的运行时数据区中,程序计数器、虚拟机栈、本地方法栈三个区域都是线程私有的,随线程而生,随线程而灭,在方法结束或线程结束时,内存自然就跟着回收了,不需要过多考虑回收的问题。
而Java堆和方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾回收器关注的是堆和方法区进行。
GC主要回答了以下三个问题:
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
就这三个问题,接下来做具体叙述。
对象存活判定算法
在堆里存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,首要的就是确定这些对象中哪些还“存活”着,哪些已经“死去”(即不可能再被任何途径使用的对象)。
1.引用计数算法
引用计数算法是在JVM中被摒弃的一种对象存活判定算法,不过它也有一些知名的应用场景(如Python、FlashPlayer),因此在这里也简单介绍一下。
用引用计数器判断对象是否存活的过程是这样的:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器加1;当引用失效时,计数器减1;任何时刻计数器为0的对象就是不可能再被使用的。
引用计数算法的实现简单,判定效率也很高,大部分情况下是一个不错的算法。它没有被JVM采用的原因是它很难解决对象之间循环引用的问题。
下面这个例子,在两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。
public class Test {
public Object instance = null;
public static void main(String[] args) {
Test a = new Test();
Test b = new Test();
a.instance = b;
b.instance = a;
a=null;
b=null;
System.gc();
}
}
对象a 和对象b都有字段instance,赋值令a.instance = b;b = a;除此之外,这两个对象再无引用。如果JVM采用引用计数算法来管理内存,这两个对象不可能再被访问,但是他们互相引用着对方,导致它们引用计数不为0,所以引用计数器无法通知GC收集器回收它们。
而事实上执行这段代码,a和b是可以被回收的,下面一节将介绍JVM实际使用的存活判定算法。
2.可达性分析算法
将 GC Roots 作为起始点进行搜索,可达的对象都是存活的ÿ