2.JVM内存模型

1.内存划分

1.1.JVM运行时内存

JVM运行时内存布局,根据 《Java 8虚拟机规范》,从概念上将其分为6个区域。分别是:

  1. 程序计数器(Program Counter Register)
  2. 虚拟机栈(VM Stack)
  3. 本地方法栈(Native Method Stack)
  4. 方法区(Method Area)。PS: jdk8中被元空间代替。
  5. 堆(Heap)
  6. 运行时常量池(JDK6在方法区内部, JDK7被独立到堆中,JDK8合并到元空间)

image

这6块区域按是否被线程共享,可以分为两大类

image

1.1.1.线程独享的内存区域

生命周期与Thread相同,即:线程创建时,相应的区域分配内存,线程销毁时,释放相应内存。

  • PC Rgesiter: 程序计数器。记录每个线程当前执行的指令(当前执行的是某条指令,下一条要执行的是哪条)
  • JVM stack: 虚拟机栈,记录每一个栈帧(Frame)中的局部变量,方法返回地址等。当一个线程去调用某个方法的时候,都回去创建Frame,方法调用完成后Frame被销毁。
  • Native Method stack: 本地方法栈,就是某个线程在调用操作系统原生方法的时候,使用的内存区域。

1.1.2.线程共享的内存区域

  • Heap: 堆内存,涉及到GC,用于存放对象的实例。
  • Method area: 方法区,主要存放类的结构、类的成员变量、static静态成员等。
  • Runtime Constant Pool:运行时常量池,方法区的一部分,主要存储类信息

严格来讲,方法区 和 运行时常量池 都应该算作是heap的一部分。 image

1.2.各区域的内存溢出

上述6个区域,除了PC Register区不会抛出异常外,其它5个区域,当请求分配的内存不足时, 均会抛出OutOfMemoryError(即:OOM)其中thread独立的VM Stack区及Native Method Stack区抛出StackOverflowError

  • StackOverflowError
    • 主要的原因是:分配给栈的内存大小是固定的(例如1MB),方法调用产生栈帧,如果栈深度太多,会产生大量栈帧,将栈内存耗尽,导致 StackOverflowError。 同样的栈帧中存储了方法的局部变量,如果局部变量数量太多的话,也会增加栈帧的大小。
    • 注意:局部变量如果没有内存逃逸的话,实例化的对象都会被分解为标量,也会占用栈内存,可以参考《逃逸分析》
    • 解决方案:当栈桢太大或虚拟机容量太小导致内存无法分配时,都会发生栈溢出异常,这个问题常常出现在递归中,适当增加栈内存并优化代码。
  • java.lang.OutOfMemoryError: unable to create new native thread
    • 无法创建线程,因为每个线程都需要分配内存且这个内存总量是固定的,比如 32 位的 Windows 最大 2G,2G = 线程数 * 虚拟机栈大小(-Xss) + 本地方法栈总量。
    • 解决方案: 主要是因为创建的线程数量过大了,可以降低线程数量,或者减少最大堆(-Xms)和减少虚拟机栈大小(-Xss)来实现。
  • Java.lang.OutOfMemoryError:java heap space
    • 常见的堆内存溢出,一般是死循环,或者超大的对象,尤其是数组类型。通常需要优化代码
  • java.lang.OutOfMemoryError:GC overhead limit exceeded
    • Sun 官方对此的定义:超过98%的时间用来做 GC 并且回收了不到 2% 的堆内存时会抛出此异常
    • 说白了就是jvm检测到堆的快耗尽,开始gc回收,多次回收之后发现回收没啥用,就报异常了
    • 一般是内存过小或者存在死循环类的问题,建议可以扩大内存试试
  • java.lang.OutOfMemoryError:Requested array size exceeds VM limit
    • 该错误由 JVM 中的 native code 抛出。JVM 在为数组分配内存之前,会执行基于所在平台的检查: 分配的数据结构是否在此平台中是可寻址的,平台一般允许分配的数据大小在1 到 21亿之间,如果超过了这个数就会抛出这种异常。 简单来说就是创建的数组太多了
  • java.lang.OutOfMemoryError:out of swap space
    • 这个就是swap区域,当内存不够的时候,会在磁盘上创建临时区域用来保存数据。Java 应用启动的时候分被分配一定的内存空间 (通过 -Xmx 及其他参数来指定)。如果此时 swap 分区大小不足或者其他进程耗尽了本机的内存,则会发生 OOM, 可以通过增大 swap 空间大小来解决,但如果在交换空间进行 GC 造成的 STW 增加大个数量级,所以增大 swap 空间一定要慎重,所以一般是通过增大本机内存或优化程序减少内存占用来解决。
  • Java.lang.OutOfMemeoryError:Permgen space
    • 永久代溢出异常,在jdk1.8以前的版本中,永久代实现了方法区的功能,保存了类、常量、静态变量、JIT的代码。
    • 如果错误地频繁地使用 String.intern()方法或运行期间生成了大量的代理类都有可能导致永久代溢出,解决方案如下
      • 是否永久代设置的过小,如果可以,适应调大一点
      • 检查代码是否有大量的反射操作
      • dump 之后通过 mat 检查是否存在大量由于反射生成的代码类
  • java.lang.OutOfMemoryError:Metadata space
    • 元数据空间溢出,jdk8新增了这个区域,并且将这个区域分配到了堆外,用于替代方法区, 所以当创建的类多的时候会出现这个线程(比如使用cglib动态生成类)
  • java.lang.OutOfMemoryError: (Native method)
    • 调用JNDI的程序生成的异常,无法分配外部的内存导致,一般是程序、配置问题导致
  • Out of memory:Kill process or sacrifice child
    • 在Linux内核的调度任务中,有一个「Out of memory killer」的调度器,它会在系统可用内存不足时被激活, 然后选择一个进程把它干掉,哪个进程会被干掉呢,简单地说会优先干掉占用内存大的应用型进程
    • 解决这种 OOM 最直接简单的方法就是升级内存,或者调整 OOM Killer 的优先级,减少应用的不必要的内存使用等等
  • 堆外内存OOM。Java.lang.OutOfMemeoryError,没有具体的信息。通常是设置了堆外内存大小或者堆外内存不足导致。

思考:JVM 堆内存溢出后,其他线程是否可继续工作?

发生OOM的线程一般情况下会死亡,也就是会被终结掉,该线程持有的对象占用的heap都会被gc了,释放内存。 因为发生OOM之前必定会因为内容不足进行gc,gc后也无法分配足够内存,导致fullGC,影响正常线程执行效率。

1.3.堆外内存

不受JVM虚拟机管控的内存区,可以通过Unsafe和NIO包下的DirectByteBuffer来操作堆外内存。 虽然堆外内存不受JVM管控,但是堆内存中会持有对它的引用,以便进行内存释放,避免内存泄漏。

注意:元数据区也是使用的堆外内存。

image

1.4.堆与栈

JVM把内存划分为"栈(stack)"与"堆(heap)"两大类,为何要这样设计?

程序运行时,内存中的信息大致分为两类,

  1. 跟程序执行逻辑相关的指令数据,这类数据通常不大,而且生命周期短,且和代码逻辑无关。
  2. 跟对象实例相关的数据,这类数据大小不定,而且可以被多个线程长时间内反复共用,比如字符串常量、缓存对象这类。

将这两类特点不同的数据分开管理,体现了软件设计上"模块隔离"的思想。stack 维护 引用量, heap 维护 字面量。 方法执行完成后,局部变量必要不会再使用,所以不用管理,直接回收即可。

image

如图中,object3同时被两个线程调用,这个时候两个线程都保存的是这个对象的一个私有的对象副本。

1.5.JDK8运行时内存区域变化

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。 虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆), 目的应该是与Java堆区分开来。 很多人都更愿意把方法区称为"永久代"(Permanent Generation)。 从jdk1.7已经开始准备"去永久代"的规划,jdk1.7的HotSpot中,已经把原本放在方法区中的静态变量、字符串常量池(不是运行时常量池)等移到堆内存中。

在jdk1.8中,永久代已经不存在,存储的类信息、编译后的代码数据等已经移动到了元空间(MetaSpace)中, 元空间使用堆外内存,只有FullGC和元空间空间不足时才会尝试回收内存(只卸载不使用的class)

image

2.JVM运行时内存

2.1.程序计数器(Program Counter Register)

程序计数器(Program Counter Register),也有称作为PC寄存器。想必学过汇编语言的朋友对程序计数器这个概念并不陌生, 在汇编语言中,程序计数器是指CPU中的寄存器,它保存的是程序当前执行的指令的地址(也可以说保存下一条指令的所在存储单元的地址), 当CPU需要执行指令时,需要从程序计数器中得到当前需要执行的指令所在存储单元的地址,然后根据得到的地址获取到指令, 在得到指令之后,程序计数器便自动加1或者根据转移指针得到下一条指令的地址,如此循环,直至执行完所有的指令。

虽然JVM中的程序计数器并不像汇编语言中的程序计数器一样是物理概念上的CPU寄存器, 但是JVM中的程序计数器的功能跟汇编语言中的程序计数器的功能在逻辑上是等同的,也就是说是用来指示执行哪条指令的。

由于在JVM中,多线程是通过线程轮流切换来获得CPU执行时间的,因此在任一具体时刻,一个CPU的内核只会执行一条线程中的指令, 因此,为了能够使得每个线程都在线程切换后能够恢复在切换之前的程序执行位置,每个线程都需要有自己独立的程序计数器, 并且不能互相被干扰,否则就会影响到程序的正常执行次序。因此,可以这么说,程序计数器是每个线程所私有的。

在JVM规范中规定,如果线程执行的是非native方法,则程序计数器中保存的是当前需要执行的指令的地址; 如果线程执行的是native方法,则程序计数器中的值是undefined。

由于程序计数器中存储的数据所占空间的大小不会随程序的执行而发生改变,因此,对于程序计数器是不会发生内存溢出现象(OutOfMemory)的。

2.2.虚拟机栈(VM Stack)

Java栈也称作虚拟机栈(Vitual Machine Stack),也就是我们常常所说的栈,跟C语言的数据段中的栈类似。 事实上,Java栈是Java方法执行的内存模型。

虚拟机栈由一个一个的栈帧组成,栈帧是在每一个方法调用时产生的,在栈帧中包括:

  • 局部变量表(Local Variables)
  • 操作数栈(Operand Stack)
  • 指向当前方法所属的类的运行时常量池的引用
  • 方法返回地址(Return Address)和一些额外的附加信息

当一个线程被创建出来的时候,这个线程就有一个虚拟机栈,栈顶的栈帧就是当前正在执行的方法。

当线程执行一个方法时,就会随之创建一个对应的栈帧,并将建立的栈帧压栈(压入的栈指的是线程的栈,所以说虚拟机栈是线程独享的)。 当方法执行完毕之后,便会将栈帧出栈,并销毁。因此可知,线程当前执行的方法所对应的栈帧必定位于Java栈的顶部。 这就是为什么在使用递归方法的时候容易导致栈内存溢出的现象了以及为什么栈区的空间不用程序员去管理了 (当然在Java中,程序员基本不用关心内存分配和释放的事情,因为Java有自己的垃圾回收机制), 这部分空间的分配和释放都是由系统自动实施的。对于所有的程序设计语言来说,栈这部分空间对程序员来说是不透明的。

栈可能出现的问题

  • 如果出现方法递归调用出现死循环的话就会造成栈帧过多,最终会抛出 StackOverflowError。
  • 若线程执行过程中栈帧大小超出虚拟机栈限制,则会抛出 StackOverflowError。
  • 若虚拟机栈允许动态扩展,但在尝试扩展时内存不足,或者在为一个新线程初始化新的虚拟机栈时申请不到足够的内存,则会抛出 OutOfMemoryError。

下面的程序模拟栈溢出。

@Slf4j
public class D4_stackOverFlowError {
	/**
	 * 递归调用方法,模拟执行方法的时候,只进栈不出栈,由于栈的大小是固定的,用不了多久就会出现StackOverflowError
	 */
	public void todo() {
		log.info("create object");
		todo();//
	}
	public static void main(String[] args) {
		new D4_stackOverFlowError().todo();
	}
}

下图表示了一个Java栈的模型:

image

2.2.1.局部变量表

局部变量表,顾名思义,就是用来存储方法中的局部变量(包括在方法中声明的非静态变量以及函数形参)。 对于基本数据类型的变量,则直接存储它的值,对于引用类型的变量,则存的是指向对象的引用。 局部变量表的大小在编译器就可以确定其大小了,因此在程序执行期间局部变量表的大小是不会改变的。

2.2.2.操作数栈

是一个先入后出的栈,存放执行程序的命令。一个线程执行方法的过程中,实际上就是不断执行语句的过程, 而归根到底就是进行计算的过程。因此可以这么说,程序中的所有计算过程都是在借助于操作数栈来完成的。

2.2.3.运行时常量池的引用

包括方法在类中的对象引用,方法中常量在类的常量池中的引用等

2.2.4.方法返回地址

当一个方法执行完毕之后,要返回之前调用它的地方,因此在栈帧中必须保存一个方法返回地址。 由于每个线程正在执行的方法可能不同,因此每个线程都会有一个自己的Java栈,互不干扰。

2.3.本地方法栈(Native Method Stack)

Java栈执行的是Java方法,而本地方法栈执行的是native的方法。

本地方法栈与Java栈的作用和原理非常相似。区别只不过是Java栈是为执行Java方法服务的,而本地方法栈则是为执行本地方法(Native Method)服务的。 在JVM规范中,并没有对本地方法栈的具体实现方法以及数据结构作强制规定,虚拟机可以自由实现它。在HotSopt虚拟机中直接就把本地方法栈和Java栈合二为一。

2.4.方法区(Method Area)

方法区在JVM中也是一个非常重要的区域,它与堆一样,是被线程共享的区域,注意:方法区是独立内存空间,不在堆内存中。 在JVM规范中,没有强制要求方法区必须实现垃圾回收,所以很多人习惯将方法区称为"永久代",是因为HotSpot虚拟机以永久代来实现方法区, 从而JVM的垃圾收集器可以像管理堆区一样管理这部分区域,从而不需要专门为这部分设计垃圾回收机制。

保存的数据如下:

  • 类信息:该内存区域也叫做 "运行时常量池"中。
  • JIT即时编译器编译后的字节码:存储的内存区域叫做 "Code Cache",不在方法区,独立内存区域存储。
  • 静态变量:静态存储区
  • 常量:常量池表

这块区域也被称为永久代,可利用参数 -XX:PermSize -XX:MaxPermSize 控制初始化方法区和最大方法区大小。

  • JDK7之前,运行时常量池 作为 方法区 的一部分。
  • JDK7,将运行时常量池从方法区(永久代)转移到堆中。
  • JDK8,元空间(Metaspace)代替 方法区,运行时常量池一同转移到元空间。常量池表被留在堆内存中。

2.4.1.运行时常量池

运行时常量池是方法区的一部分,里面存储的是类的定义信息,例如类、方法、字段、接口的描述信息(并不是class字节码)。其中也包括 常量池表 这些类的字节码可能没有被jvm编译,只是经过加载到jvm中

在类和接口被加载到JVM后,对应的运行时常量池就被创建出来。当然并非Class文件常量池中的内容才能进入运行时常量池,在运行期间也可将新的常量放入运行时常量池中, 比如String的intern方法。

下图就是class在运行时常量池中存储的信息。可以看到 常量池 也在其中,只不过这部分数据存在在堆内存中,这里只有引用。

2.4.2.Code Cache

用来存储编译期间生成的字面量和符号引用特指,被JIT编译后的代码,可以将其理解为热代码,需要保存到方法区中, 而非编译的代码只有在使用的时候才会根据类信息,从本地磁盘加载字节码信息,通过解释器进行程序运行,等这部分代码执行的次数 变多之后,才会将其交给翻译器,并将翻译好的字节码保存到方法区

CodeCache client模式下默认最大是32m,在server模式下默认是48m。调整大小

-XX:ReservedCodeCacheSize=128m
-XX:InitialCodeCacheSize=128m

CodeCache缓存区是可能被充满的,当CodeCache满时,后台会收到CodeCache is full的警告信息,如下所示:

"CompilerThread0" java.lang.OutOfMemoryError: requested 2854248 bytes for Chunk::new. Out of swap space?

2.4.3.静态存储区

静态变量(也称为类变量)是类级别的变量,与类的实例无关,它们被类的所有实例共享。

在方法区中,静态变量被保存在一个称为静态变量区或静态存储区的地方。这个区域专门用于存储静态变量和静态常量(即使用static和final修饰的变量)。 静态变量在类加载时被初始化,并在程序运行期间一直存在,直到类被卸载。 JDK8之后,静态存储区 也进去到元空间中。

2.4.4.常量池

常量池里存储着字面量和符号引用,看上图可以知道常量池也是运行时常量池中的一部分,作为类元数据的一部分动态存储在方法区(或元空间)中。 它与每个类紧密相关,随着类的加载而创建,并随着类的卸载而销毁。

2.4.4.1.字符串常量池

其中比较特殊的是字符串常量池,对象数据存储在堆内存中。

过程如下:在类加载完成,经过验证,准备阶段之后在会在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到string pool中。

注意:string pool中存的是引用值而不是具体的实例对象,具体的实例对象在堆中。

在HotSpot VM里实现的string pool功能的是一个StringTable类,它是一个哈希表,里面存的是驻留字符串的引用(而不是驻留字符串实例本身), 也就是说在堆中的某些字符串实例被这个StringTable引用之后就等同被赋予了"驻留字符串"的身份。 这个StringTable在每个HotSpot VM的实例只有一份,被所有的类共享。

  1. 字符串池常量池在每个VM中只有一份,存放的是字符串常量的引用值,对象其实存放在堆中。
  2. class常量池是在编译的时候每个class都有的,在编译阶段,存放的是常量的符号引用,引用指向堆内存中的字符串对象实例。
  3. 运行时常量池是在类加载完成之后,将每个class常量池中的符号引用值转存到运行时常量池中,也就是说,每个class都有一个运行时常量池, 类在解析之后,将符号引用替换成直接引用,与全局常量池中的引用值保持一致。

当 new String的时候,会检查这个区域是否有这个符号的引用。

public static void main(String[] args) {
  String s1 = "abc";//"abc" 保持在运行时常量池中
  String s2 = "abc";//"abc" 保持在运行时常量池中
  String s3 = new String("abc");//新对象
  System.out.println(s1 == s2);//true
  System.out.println(s1 == s3);//false
  System.out.println(s2 == s3);//false
  //true public native String intern()直接获得字符串对象的值在常量池中的值
  System.out.println(s2 == s3.intern());
}
2.4.4.2.其他常量池

基础类型,不需要常量池,作为字面量存储在运行时常量池的常量池表中接即可。

而pojo而在常量池存储对象的应用即可。当然几乎没有对象的常量,通过会将其对应为静态常量,存在到"静态存储区"中。

2.5.堆(Heap)

在C语言中,堆这部分空间是唯一一个程序员可以管理的内存区域。程序员可以通过malloc函数和free函数在堆上申请和释放空间。

Java中的堆是用来存储对象实例。只不过和C语言中的不同,在Java中,程序员基本不用去关心空间释放的问题, Java的垃圾回收机制会自动进行处理。因此这部分空间也是Java垃圾收集器管理的主要区域。另外,堆是被所有线程共享的, 在JVM中只有一个堆,是整个虚拟机所管理的最大内存区域,所有的对象创建都是在这个区域进行内存分配。

可利用参数 -Xms -Xmx 进行堆内存控制。

这块区域也是垃圾回收器重点管理的区域,由于大多数垃圾回收器都采用分代回收算法,所有堆内存也分为新生代、老年代, 可以方便垃圾的准确回收。这块内存属于线程共享区域。

2.6.元数据区(JDK1.8)

在 JDK1.8 中已经移除了方法区(永久代),并使用了一个元数据区域进行代替(Metaspace)。

元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于: 元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小:

  • -XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值; 如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
  • -XX:MaxMetaspaceSize,最大空间,默认是没有限制的。

除了上面两个指定大小的选项以外,还有两个与 GC 相关的属性:

  • -XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
  • -XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集

注意:默认情况下元数据区域会根据使用情况动态调整,避免了在 1.7 中由于加载类过多从而出现 java.lang.OutOfMemoryError:PermGen。 但也不能无线扩展,因此可以使用 -XX:MaxMetaspaceSize 来控制最大内存

如果你设置的元内存空间过小,你的应用程序可能得到以下错误: java.lang.OutOfMemoryError: Metadata space

在Java7之前,HotSpot虚拟机中将GC分代收集扩展到了方法区,使用永久代来实现了方法区。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载。 而在Java8中,已经彻底没有了永久代,将方法区直接放在一个与堆不相连的本地内存区域,这个区域被叫做元空间。

3.Java内存模型(JMM)

3.1.计算机的内存模型

CPU registers是cup的寄存器,对于CPU来说,是访问速度最快的。

为了使CPU访问内存的速度尽可能达到和寄存器一样,所以每一个CPU都有一个或多个内部的高速缓存,作为CPU与主存之间的过度。

image 而Java的堆栈既可以存在在寄存器、多级缓存以及内存中。

image

3.2.JMM抽象结构图

我们定义的共享变量保存在主内存中。

当其他线程访问这个共享变量的时候,JMM会将共享变量的副本交给线程,保存副本到线程自己的内存中,这个副本其实不是真实存在的(也就是不占用内存)。

本地内存是JMM的一个抽象概念,并不真实存在,你可以理解为"CPU高速缓冲",在代码层面没有任何体现

image

3.3.同步操作与规则

image

当多线程操作共享数据的时候,我们应该使用的同步操作如下

  1. 多线程读取共享数据的时候,程序将主内存中的数据进行加锁操作(确保数据的一致性,保证数据安全)
  2. 加锁后,JMM将共享数据的副本加载到线程的工作内存中,这时线程就可以操作共享数据了
  3. 比如我们对共享数据进行了修改,被修改的数据保持到工作内存的副本中
  4. 然后执行store保存操作将数据写到主内存的
  5. 操作完成后,主内存的共享数据释放锁。

注意:

  1. 主内存中的共享数据,只能被一个线程lock,不能被多个线程lock。
  2. 当数据被lock后,可能被同一个线程多次lock,但是需要进行与lock次数相同的unlock才能释放锁

3.4.主内存与工作内存八种操作指令

  • lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态;
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用;
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中;
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作;
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作;
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用;
  • write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中;

分类:

  • lock and unlock 加解锁
  • read and load 从主存复制变量到当前工作内存
  • use and assign 执行代码,改变共享变量值
  • store and write 用工作内存数据刷新主存相关内容

3.4.Java 程序执行过程

image

  1. Java源代码文件(.java)会被Java编译器编译为字节码文件(.class)
  2. JVM中的类加载器加载各个类的字节码文件,加载完毕之后,交由JVM执行引擎执行。
  3. 在整个程序执行过程中,JVM会用一段空间来存储程序执行期间需要用到的数据和相关信息,这段空间一般被称作为Runtime Data Area(运行时数据区),也就是我们常说的JVM内存。

因此,在Java中我们常常说到的内存管理就是针对这段空间进行管理(如何分配和回收内存空间)

4.Heap(堆内存模型)

这里以jdk1.8默认状态下的分代内存为例。堆内存模型会根据垃圾回收器不同而变化。

4.1.堆内存划分

JVM区域总体分两类,heap区和非heap区(其中堆占的比重最大)

  • heap区划分为:
    • Eden Space(伊甸园)
    • Survivor Space(幸存者区)
    • Old Gen(老年代)
  • 非heap区划分:
    • code cache(代码缓存区)
    • Perm Gen(永久代);或者Matespace(元数据区)
    • Jvm Stack(java虚拟机栈)
    • NativeMethod Statck(本地方法栈)

image

image

Java 堆(Java Heap)是JVM所管理的内存中最大的一块,堆又是垃圾收集器管理的主要区域,区域划分

  1. 新生代:朝生夕死,发生gc的密集区域
    1. Eden:字面意思是伊甸园,对象被创建的时候首先放到这个区域,进行垃圾回收后,不能被回收的对象被放入到空的survivor区域。
    2. Survivor From:幸存者区,用于保存在eden space中经过垃圾回收后没有被回收的对象。
    3. Survivor To:幸存者区,用于保存在eden space中经过垃圾回收后没有被回收的对象。
  2. 老年代:用于存放新生代中经过多次垃圾回收仍然存活的对象,也有可能是新生代分配不了内存的大对象会直接进入老年代。

Survivor为什么两个?

Survivor To 和 Survivor From,两个区域大小相同,两种同时只能一个为空,一个不为空。

  • 当新生代GC时,Eden不能被回收的对象被放入到空的Survivor From,然后整个Eden空间被清理(内存不释放,只是清空内存数据)。
  • 下次当新生代GC时,Survivor From 和 Eden 中不能被回收的对象,会进去Survivor To。Survivor From 和 Eden 被清理。
  • To 和 From 空间角色互换,始终保证一个survivor是空的。

eden与survivor的比例是为什么是8:1?

IBM做过一份研究,新生代的对象98%熬不过第一轮收集。

image

4.2.内存分配策略

  • 优先eden:新生代垃圾回收(Minor GC、Young GC),存活的对象age加1。
  • 对象直接进去老年代
  • eden经过多次gc,进去survivor后达到阈值进去老年代。-XX:MaxTenuringThreshold=15。直到达到阈值(默认为15)就移动到老年代中。
  • 动态对象年龄判断。survivor空间不足时,选取年龄最大的对象,提前进入老年代
  • 老年代内存不足时:触发Major GC。在CMS中Major GC通常会伴随着Minor G,所以在统计时有时Major GC被算作FullGC

默认情况下

  • 年轻代:老年代 = 1:2。 -XX:NewRatio=2
  • Eden:s1:s2=8:1:1。当然也支持参数调整 -XX:SurvivorRatio=8

整个堆大小=年轻代大小 + 老年代大小。

堆内存默认为物理内存的1/64(<1GB)。

  • 默认空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制,可以通过MinHeapFreeRatio参数进行调整;
  • 默认空余堆内存大于70%时,JVM会减少堆直到-Xms的最小限制,可以通过MaxHeapFreeRatio参数进行调整。

什么是大对象?

所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。 代码中应当避免使用,经常出现大对象可能导致 老年代 空间不足,而触发Major GC。

-XX:PretenureSizeThreshold 设置大对象的阈值,作用于新生代,该参数只对Serial和ParNew两款收集器有效, 如果遇到必须使用此参数的场合,可以考虑ParNew加CMS的收集器组合。

逃逸分析?

这里涉及到一个重要的概念,逃逸分析。如果方法中创建的局部变量没有发生逃逸,对象大小合适,栈内存足够, 且对象内存所有的成员都可以被拆解为标量(基础类型,或者常量池中的数据)就会优先进行栈上分配。

所以经过JVM优化后,最终可能的分配顺序是: 栈上分配->TLAB(线程独享的Eden区域)->Eden(会发生内存分配竞争)->Old

4.3.空间分配担保

  • 在发生Minor GC之前,JVM先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么Minor GC确认是安全的;
  • 如果不成立的话虚拟机就会检查HandlePromotionFailure设置值是否允许担保失败,如果允许就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,
  • 如果大于,将尝试进行着一次Minor GC,尽管这次Minor GC是有风险的,可能触发Full GC
  • 如果小于或者HandlePromotionFailure设置不允许冒险,就会直接Full GC

分配担保最关键作用是,在进行Minor GC前是否需要根据历届晋升到老年代的平均值来发起一次Full GC。

分配担保机制可以不要,但会导致Full GC更容易发生,进而导致所谓的STW,虚拟机短暂停止,吞吐量,性能下降。 有了分配担保机制,就可以借鉴经验值来减少Full GC这种耗时降低性能行为。

4.4.程序模拟

模拟Eden区被占有

public static void main(String[] args) {
  // -verbose:gc -XX:+PrintGCDetails -XX:+UseSerialGC -Xms20m -Xmx20m -Xmn10m
  // 1.创建普通对象,对象保存在Eden区
  byte[] b1 = new byte[1024 * 1024];
  System.gc();
}

image

模拟大对象被分配到老年代

public static void main(String[] args) {
  // -verbose:gc -XX:+PrintGCDetails -XX:+UseSerialGC -Xms20m -Xmx20m -Xmn10m
  // 2.创建大对象,对象保存在老年代
  byte[] b2 = new byte[1024 * 1024 * 9];
  System.gc();
}

image

4.5.TLAB

TLAB(Thread Local Allocation Buffer-线程本地分配缓冲区)。在JVM中每个线程都有一个TLAB,用于在堆内存中分配对象。

作用:

  1. 提高对象分配的性能:TLAB是内存独享,所以不同线程之间不会互相竞争同一个内存区域的锁,从而减少了锁的争用和等待时间。
  2. 由于TLAB是连续的内存区域,所以在分配内存时也可以减少碎片化的问题。

线程中分配内存的过程:当一个线程需要分配对象时,JVM首先会尝试在该线程的TLAB中分配内存,如果TLAB中的空间不足,则会在堆内存中分配内存。

需要注意的是,TLAB的大小是可以调整的,过小会导致频繁地回到堆内存中分配内存,过大则可能会导致浪费内存。因此,在实际应用中需要根据具体情况进行调整。

  • -XX:+UseTLAB:表示是否使用TLAB。默认开启
  • -XX:TLABSize:用于设置TLAB的大小。默认大小为Eden 1%
  • -XX:TLABRefillWasteFraction:设置进入TLAB空间单个对象大小的比例值
  • -XX:+PrintTLAB: 查看TLAB信息

TLAB 与 栈内存的区别:

  1. 栈内存是线程自己的内存,用于存储局部变量、方法调用信息等,也可以用于对象的栈上分配,数据位于虚拟机栈中,JVM不用处理GC
  2. TLAB是JVM为线程分配的堆内存区域,需要JVM处理GC。

5.堆外内存的使用

5.1.介绍

堆外内存不是JVM运行时数据区的一部分,也不是JVM规范中定义的内存区域, 这部分内存区域直接被操作系统管理.

  1. JDK 1.4以前, 堆外内存访问没有光明正大的做法: 只能通过反射拿到Unsafe类,然后调用allocateMemory()/freeMemory()来申请/释放这块内存。
  2. JDK 1.4开始新加入了NIO, 它引入了一种基于Channel与Buffer的I/O方式,可以使用Native函数库直接分配堆外内存, 然后通过DirectByteBuffer对象作为这块内存的引用进行操作(DirectByteBuffer也是通过Unsafe的allocateMemory/freeMemory操作内存的)。

DirectByteBuffer 操作堆外内存的方法如下:

image

5.2.使用场景

其实很多框架,尤其是中间件都喜欢使用堆外内存

  1. 缓存:OHC、Ehcache、MapDB、Chronicle Map
  2. 中间件: Kafka,Elasticsearch,rocketmq,其中使用的MMAP、PageCache等技术,就是堆外内存的一种使用方式。
  3. 大数据: Hadoop、spark。

问题:依赖os cache的系统JVM内存越大越好?

kafka、elasticsearch等系统,依赖os cache来缓冲大量数据的。例如32G的服务器,JVM分配20G,os cache剩下的内存可能就不到10G。 os cache不足,就可能使用sweep(使用磁盘作为内存),导致服务的性能急剧下降。

所以说,针对类似Kafka、Elasticsearch这里类型的服务,应该要给JVM分配6GB或者再多一点的内存即可。 因为他们可能不需要耗费过大的内存空间,不依赖JVM内存管理数据,当然具体是设置多少,需要你精准的压测和优化。


文章信息

张雪随笔
master
Homepage
Issues
Download

This Software is under the terms of The Unlicense.