21 | 磨刀不误砍柴工:欲知JVM调优先了解JVM内存模型

你好,我是刘超。

从今天开始,我将和你一起探讨Java虚拟机(JVM)的性能调优。JVM算是面试中的高频问题了,通常情况下总会有人问到:请你讲解下JVM的内存模型,JVM的性能调优做过吗?

为什么JVM在Java中如此重要?

首先你应该知道,运行一个Java应用程序,我们必须要先安装JDK或者JRE包。这是因为Java应用在编译后会变成字节码,然后通过字节码运行在JVM中,而JVM是JRE的核心组成部分。

JVM不仅承担了Java字节码的分析(JIT compiler)和执行(Runtime),同时也内置了自动内存分配管理机制。这个机制可以大大降低手动分配回收机制可能带来的内存泄露和内存溢出风险,使Java开发人员不需要关注每个对象的内存分配以及回收,从而更专注于业务本身。

从了解内存模型开始

JVM自动内存分配管理机制的好处很多,但实则是把双刃剑。这个机制在提升Java开发效率的同时,也容易使Java开发人员过度依赖于自动化,弱化对内存的管理能力,这样系统就很容易发生JVM的堆内存异常,垃圾回收(GC)的方式不合适以及GC次数过于频繁等问题,这些都将直接影响到应用服务的性能。

因此,要进行JVM层面的调优,就需要深入了解JVM内存分配和回收原理,这样在遇到问题时,我们才能通过日志分析快速地定位问题;也能在系统遇到性能瓶颈时,通过分析JVM调优来优化系统性能。这也是整个模块四的重点内容,今天我们就从JVM的内存模型学起,为后续的学习打下一个坚实的基础。

JVM内存模型的具体设计

我们先通过一张JVM内存模型图,来熟悉下其具体设计。在Java中,JVM内存模型主要分为堆、程序计数器、方法区、虚拟机栈和本地方法栈。

JVM的5个分区具体是怎么实现的呢?我们一一分析。

1. 堆(Heap)

堆是JVM内存中最大的一块内存空间,该内存被所有线程共享,几乎所有对象和数组都被分配到了堆内存中。堆被划分为新生代和老年代,新生代又被进一步划分为Eden和Survivor区,最后Survivor由From Survivor和To Survivor组成。

在Java6版本中,永久代在非堆内存区;到了Java7版本,永久代的静态变量和运行时常量池被合并到了堆中;而到了Java8,永久代被元空间取代了。 结构如下图所示:

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

程序计数器是一块很小的内存空间,主要用来记录各个线程执行的字节码的地址,例如,分支、循环、跳转、异常、线程恢复等都依赖于计数器。

由于Java是多线程语言,当执行的线程数量超过CPU核数时,线程之间会根据时间片轮询争夺CPU资源。如果一个线程的时间片用完了,或者是其它原因导致这个线程的CPU资源被提前抢夺,那么这个退出的线程就需要单独的一个程序计数器,来记录下一条运行的指令。

3. 方法区(Method Area)

很多开发者都习惯将方法区称为“永久代”,其实这两者并不是等价的。

HotSpot虚拟机使用永久代来实现方法区,但在其它虚拟机中,例如,Oracle的JRockit、IBM的J9就不存在永久代一说。因此,方法区只是JVM中规范的一部分,可以说,在HotSpot虚拟机中,设计人员使用了永久代来实现了JVM规范的方法区。

方法区主要是用来存放已被虚拟机加载的类相关信息,包括类信息、运行时常量池、字符串常量池。类信息又包括了类的版本、字段、方法、接口和父类等信息。

JVM在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。在加载类的时候,JVM会先加载class文件,而在class文件中除了有类的版本、字段、方法和接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期间生成的各种字面量和符号引用。

字面量包括字符串(String a=“b”)、基本类型的常量(final修饰的变量),符号引用则包括类和方法的全限定名(例如String这个类,它的全限定名就是Java/lang/String)、字段的名称和描述符以及方法的名称和描述符。

而当类加载到内存中后,JVM就会将class文件常量池中的内容存放到运行时的常量池中;在解析阶段,JVM会把符号引用替换为直接引用(对象的索引值)。

例如,类中的一个字符串常量在class文件中时,存放在class文件常量池中的;在JVM加载完类之后,JVM会将这个字符串常量放到运行时常量池中,并在解析阶段,指定该字符串对象的索引值。运行时常量池是全局共享的,多个类共用一个运行时常量池,class文件中常量池多个相同的字符串在运行时常量池只会存在一份。

方法区与堆空间类似,也是一个共享内存区,所以方法区是线程共享的。假如两个线程都试图访问方法区中的同一个类信息,而这个类还没有装入JVM,那么此时就只允许一个线程去加载它,另一个线程必须等待。

在HotSpot虚拟机、Java7版本中已经将永久代的静态变量和运行时常量池转移到了堆中,其余部分则存储在JVM的非堆内存中,而Java8版本已经将方法区中实现的永久代去掉了,并用元空间(class metadata)代替了之前的永久代,并且元空间的存储位置是本地内存。之前永久代的类的元数据存储在了元空间,永久代的静态变量(class static variables)以及运行时常量池(runtime constant pool)则跟Java7一样,转移到了堆中。

那你可能又有疑问了,Java8为什么使用元空间替代永久代,这样做有什么好处呢?

官方给出的解释是:

  • 移除永久代是为了融合 HotSpot JVM 与 JRockit VM 而做出的努力,因为JRockit没有永久代,所以不需要配置永久代。
  • 永久代内存经常不够用或发生内存溢出,爆出异常java.lang.OutOfMemoryError: PermGen。这是因为在JDK1.7版本中,指定的PermGen区大小为8M,由于PermGen中类的元数据信息在每次FullGC的时候都可能被收集,回收率都偏低,成绩很难令人满意;还有,为PermGen分配多大的空间很难确定,PermSize的大小依赖于很多因素,比如,JVM加载的class总数、常量池的大小和方法的大小等。

4.虚拟机栈(VM stack)

Java虚拟机栈是线程私有的内存空间,它和Java线程一起创建。当创建一个线程时,会在虚拟机栈中申请一个线程栈,用来保存方法的局部变量、操作数栈、动态链接方法和返回地址等信息,并参与方法的调用和返回。每一个方法的调用都伴随着栈帧的入栈操作,方法的返回则是栈帧的出栈操作。

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

本地方法栈跟Java虚拟机栈的功能类似,Java虚拟机栈用于管理Java函数的调用,而本地方法栈则用于管理本地方法的调用。但本地方法并不是用Java实现的,而是由C语言实现的。

JVM的运行原理

看到这里,相信你对JVM内存模型已经有个充分的了解了。接下来,我们通过一个案例来了解下代码和对象是如何分配存储的,Java代码又是如何在JVM中运行的。

public class JVMCase {

	// 常量
	public final static String MAN_SEX_TYPE = "man";

	// 静态变量
	public static String WOMAN_SEX_TYPE = "woman";

	public static void main(String[] args) {
		
		Student stu = new Student();
		stu.setName("nick");
		stu.setSexType(MAN_SEX_TYPE);
		stu.setAge(20);
		
		JVMCase jvmcase = new JVMCase();
		
		// 调用静态方法
		print(stu);
		// 调用非静态方法
		jvmcase.sayHello(stu);
	}


	// 常规静态方法
	public static void print(Student stu) {
		System.out.println("name: " + stu.getName() + "; sex:" + stu.getSexType() + "; age:" + stu.getAge()); 
	}


	// 非静态方法
	public void sayHello(Student stu) {
		System.out.println(stu.getName() + "say: hello"); 
	}
}

class Student{
	String name;
	String sexType;
	int age;
	
	public String getName() {
		return name;
	}
	public void setName(String name) {
		this.name = name;
	}
	
	public String getSexType() {
		return sexType;
	}
	public void setSexType(String sexType) {
		this.sexType = sexType;
	}
	public int getAge() {
		return age;
	}
	public void setAge(int age) {
		this.age = age;
	}
}

当我们通过Java运行以上代码时,JVM的整个处理过程如下:

1.JVM向操作系统申请内存,JVM第一步就是通过配置参数或者默认配置参数向操作系统申请内存空间,根据内存大小找到具体的内存分配表,然后把内存段的起始地址和终止地址分配给JVM,接下来JVM就进行内部分配。

2.JVM获得内存空间后,会根据配置参数分配堆、栈以及方法区的内存大小。

3.class文件加载、验证、准备以及解析,其中准备阶段会为类的静态变量分配内存,初始化为系统的初始值(这部分我在第21讲还会详细介绍)。

4.完成上一个步骤后,将会进行最后一个初始化阶段。在这个阶段中,JVM首先会执行构造器<clinit>方法,编译器会在.java 文件被编译成.class 文件时,收集所有类的初始化代码,包括静态变量赋值语句、静态代码块、静态方法,收集在一起成为 <clinit>() 方法。

5.执行方法。启动main线程,执行main方法,开始执行第一行代码。此时堆内存中会创建一个student对象,对象引用student就存放在栈中。

6.此时再次创建一个JVMCase对象,调用sayHello非静态方法,sayHello方法属于对象JVMCase,此时sayHello方法入栈,并通过栈中的student引用调用堆中的Student对象;之后,调用静态方法print,print静态方法属于JVMCase类,是从静态方法中获取,之后放入到栈中,也是通过student引用调用堆中的student对象。

了解完实际代码在JVM中分配的内存空间以及运行原理,相信你会更加清楚内存模型中各个区域的职责分工。

总结

这讲我们主要深入学习了最基础的内存模型设计,了解其各个分区的作用及实现原理。

如今,JVM在很大程度上减轻了Java开发人员投入到对象生命周期的管理精力。在使用对象的时候,JVM会自动分配内存给对象,在不使用的时候,垃圾回收器会自动回收对象,释放占用的内存。

但在某些情况下,正常的生命周期不是最优的选择,有些对象按照JVM默认的方式,创建成本会很高。比如,我在第03讲讲到的String对象,在特定的场景使用String.intern可以很大程度地节约内存成本。我们可以使用不同的引用类型,改变一个对象的正常生命周期,从而提高JVM的回收效率,这也是JVM性能调优的一种方式。

思考题

这讲我只提到了堆内存中对象分配内存空间的过程,那如果有一个类中定义了String a="b"和String c = new String(“b”),请问这两个对象会分别创建在JVM内存模型中的哪块区域呢?

期待在留言区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起讨论。

精选留言

  • 张学磊

    2019-07-09 14:00:40

    String a="b"可能创建一个对象或者不创建对象,如果"b"这个字符串在常量池里不存在会在常量池创建一个String对象"b",如果已经存在则a直接reference to这个常量池里的对象;
    String c= new String("b")至少创建一个对象,也可能两个,因为用到new关键字,会在堆内在创建一个的String对象,它的值是"b"。同时,如果"b"这个字符串在常量池里不存在,会在常量池创建这个一个String对象"b"。
    作者回复

    对的

    2019-07-10 09:51:20

  • Xiao

    2019-07-09 09:31:39

    老师,这儿其实应该说JVM内存结构更合适!JVM内存模型是一种规范,和JVM内存结构不是一个概念。其次,元空间,在Java8,不是在堆内分配的,它的大小是依赖于本地内存大小!
    作者回复

    感谢Xiao同学的提醒。

    我想你说的内存模型应该是指Java内存模型(JMM)吧。这里的JVM内存模型跟Java内存模型是不一样的,这里的JVM内存模型和和内存结构是一个意思。

    元空间是分配的本地内存,文中开始描述不清楚(已纠正),但后面有明确说明。

    2019-07-09 15:04:02

  • Liam

    2019-07-09 08:08:58

    请教一个问题,所以1.8开始,方法区是堆的一部分吗?也即是说,方法区的大小受限于堆
    作者回复

    方法区不是堆的一部分,方法区和堆存在交集。方法区的静态变量和运行时常量池存放在堆中,但类的元信息等还是存放在了本地内存中。

    2019-07-09 09:25:09

  • 夏天39度

    2019-07-31 10:18:11

    超哥,我可以这样理解吗,方法区只是一个逻辑概念,方法区是包括元空间物理内存和堆内存
    作者回复

    对的

    2019-08-02 10:29:06

  • Gred

    2019-08-02 00:04:52

    老师,运行时变量应该都在方法区中,从java7开始只有字符串常量池移到堆中而已
    作者回复

    严格来说,是静态常量池和运行时常量池,静态常量池是存放字符串字面量、符号引用以及类和方法的信息,而运行时常量池存放的是运行时一些直接引用。

    运行时常量池是在类加载完成之后,将静态常量池中的符号引用值转存到运行时常量池中,类在解析之后,将符号引用替换成直接引用。

    这两个常量池在JDK1.7版本之后,就移到堆内存中了,这里指的是物理空间,而逻辑上还是属于方法区(方法区是逻辑分区)。

    2019-08-02 10:59:05

  • 我又不乱来

    2019-07-09 07:42:01

    String a="b"应该会放在字符串常量池中。
    String c= new String("b") 首先应该放在 堆中一份,再在常量池中放一份。但是常量池中有b了。
    第一次留言。不知道理解的对不对。超哥
    作者回复

    正确

    2019-07-09 09:20:34

  • 发条橙子 。

    2019-07-20 18:20:47

    老师,这句话怎么理解

    之前永久代的类的元数据存储在了元空间,永久代的静态变量(class static variables)以及运行时常量池(runtime constant pool)则跟 Java7 一样,转移到了堆中。

    方法区的一部分是由永久代实现的,永久代主要存储类的静态数据以及运行时常量池并储存在堆内存中。 但是由于容易发生permen内存溢出,后来就发明了元数据空间。那我理解元空间除了存储之前方法区的类信息还包括之前放在永久代中的 静态变量 和 运行时常量池 。
    文中为什么说和jdk7一样还是转移到堆中,那不是没有变化么?
    作者回复

    是的,没有变化。

    2019-07-30 10:11:48

  • 尔东橙

    2019-09-02 18:14:21

    老师,我看有人说字符串常量池只放引用;那new出来除了堆中会有一个对象,如果字符串常量池没有,也会创建一个,这个对象是在堆中非字符串常量池的地方么
    作者回复

    我们说的常量池一般分为静态常量池和运行时常量池,通常字符串常量是存放的引用是在运行时常量池,而字面量是存在了静态常量池。动态生成的字符串,对象是存放在堆中,如果调用intern方法,会将引用存放在常量池中。

    2019-09-02 19:20:50

  • 黑夜里的猫

    2019-07-09 19:50:20

    字符串常量不是在java8中已经被放入到堆中了吗,应该不在方法区中了,但是看到老师的图中还在方法区中
    作者回复

    方法区是一个规范,并不是一个物理空间,我们这里说的字符串常量放在堆内存空间中,是指实际的物理空间。

    2019-07-12 09:43:17

  • 东方奇骥

    2019-07-12 12:54:46

    老师,问一下,1.8静态变量和常量存储在的堆里面,那元空间里是什么?文中说之前永久带类的数据存储在了元空间,不是很理解,
    作者回复

    元空间主要存储类的一些信息,包括方法、字段、类等描述类信息。

    2019-07-17 11:19:13

  • 晓杰

    2019-07-11 15:48:04

    创建一个线程,就会在虚拟机中申请一个栈帧,这句话有问题吧
    应该是创建一个线程,会创建一个栈,然后方法调用一次,就会申请一个栈帧吧
    作者回复

    对的,这里是申请一个线程栈。

    2019-07-12 11:26:47

  • Cain

    2019-07-10 19:30:35

    常量池在哪个区?堆区?栈区?方法区?静态区?方法区,静态区他俩是什么关系?
    作者回复

    在逻辑空间是属于方法区。堆、栈、方法区等,这些是一种规范,是逻辑上的分区。

    在物理空间中,常量池是存储在堆内存空间的。

    2019-07-12 10:05:55

  • crazypokerk

    2019-07-09 09:48:09

    引用a和c都会放在栈中,但是a直接指向堆中的运行时常量池中的"b",而引用c会先在堆中创建一个String对象,该对象会指向运行时常量池中的"b"。
    作者回复

    厉害

    2019-07-10 09:40:29

  • ZHANG

    2019-10-31 20:51:30

    老师,是这样吗,java8中类的静态变量,运行时常量池,字符串常量池都在堆中,那元空间只有一些类的信息了,比如版本什么的。
    作者回复

    是的

    2020-03-18 21:02:51

  • 帽子丨影

    2019-08-20 18:58:32

    元空间的存储位置时本地内存,请问下本地内存是个什么东西,在第一张图里没找到啊。
    作者回复

    本地内存是一种非JVM堆内存

    2019-09-08 20:49:44

  • Alpha

    2019-07-09 21:40:02

    而到了 Java8,静态变量和运行时常量池与 Java7 的永久代一样,都移到了堆中。

    这句没看懂。。上一句说到java7把永久代里的静态变量和运行时常量池移到堆中,这一句又说java8移了 静态变量和运行时常量池?
    作者回复

    已捋顺,也就是说静态变量和运行时常量池依然存储在堆内存物理空间中。

    2019-07-10 10:05:17

  • joker

    2020-06-05 14:15:32

    超哥,您好,不知道您还能不能看到留言并回复。
    在学习JVM的内存模型的时候,我有这样一些疑惑:
    1、我们通常只是定义了堆大小(-Xms初始,-Xmx最大),虚拟机栈大小(-Xss)。
    但是我发现这并不能计算出一个java进程占用的全部内存大小。
    以下是我自己理解的(JDK1.8):
    java进程占用的内存
    =JVM管理的内存+非JVM管理的内存
    =线程独立的内存+线程共享的内存
    =n*(虚拟机栈内存+程序计数器内存+本地方法栈内存)+堆内存(heap)+非堆内存(non-heap)+元空间(metaspace)+堆外内存(off-heap:direct memory)
    其中:
    JVM管理的内存:n*(虚拟机栈内存+程序计数器内存+本地方法栈内存)+堆内存(heap)+非堆内存(non-heap)
    非JVM管理的内存:元空间(metaspace)+堆外内存(off-heap:direct memory)
    线程独立的内存:n*(虚拟机栈内存+程序计数器内存+本地方法栈内存),n是线程数
    线程共享的内存:堆内存(heap)+非堆内存(non-heap)+元空间(metaspace)+对外内存(off-heap:direct memory)
    以上我的理解是否正确呢?麻烦超哥指点


    2、在JDK1.7及以前,有个永久代(PermGen),也就是文中说的方法区。这块区域也被称为非堆内存
    那么在JDK1.8及以后,永久代变成了元空间,到了JVM管理之外了,那么JDK1.8及以后的版本中还有非堆内存(non-heap)的说法吗?如果有的话,是指什么呢?

    3、关于线程独立的这块内存{n*(虚拟机栈内存+程序计数器内存+本地方法栈内存),n是线程数},它是完全独立于其他的内存的吗?
    还是会分享堆内存,受到堆内存大小的限制
    还是说Thread对象是建立在堆内存,然后每个Thread对应的虚拟机栈都是独立的吗? 换句话说,随着Thread的增加(堆内存充足:还能给新的对象分配内存),java进程占用的内存会越来越大-----我觉得这肯定不对,但是我却无法解释

    4、我做了一些测试(JDK1.8):
    4.1、指定很小的堆内存,改变虚拟机栈大小
    4.1.1、-Xms2m -Xmx2m -Xss16m 启动java进程,直到递归调用1,016,085深度,会报StackOverflowError
    4.1.2、-Xms2m -Xmx2m -Xss8m 启动java进程,直到递归调用318,031深度,会报StackOverflowError
    4.2、指定很小的堆内存,如-Xms2m -Xmx2m,最终会报OutOfMemoryError
  • knightyxgy

    2020-05-03 22:35:59

    请教老师,看了您对其他留言的回答给予了肯定:1、JVM的堆和操作系统的堆不是一个概念。2、静态常量池和运行时常量池移入的堆内存是指物理内存,逻辑上还是属于JVM对于方法区的规范。3、方法区实际上是在本地内存即堆外内存分配的
    根据老师的回答理解下来,两个常量池移入的堆内存就是操作系统层次的概念,可是JVM内存无论是堆内存还是非堆内存(元空间)应该都是操作系统抽象的堆内存中分配的吧。如何理解操作系统层次的堆和JVM的堆以及本地内存之间的关系呢?
  • 斐波那契

    2019-11-23 22:25:06

    老师 看完这个 对于运行时常量池和字符串常量池有点搞不清 是不是运行时常量池包括字符串常量池还是说这两个是不同的东西?
    作者回复

    两者有区别,通常方法区中有静态常量池和运行时常量池,静态常量池主要存储的是字面量以及符号引用等信息,而运行时常量池存储的是类运行加载时生成的直接引用等信息。静态常量池也包括了我们说的字符串常量池。

    2019-11-24 15:31:19

  • 文灏

    2019-07-15 21:24:15

    请教一下,1.8中类的元数据是放在元数据区还是方法区呢?看得有点晕
    作者回复

    元空间是属于方法区的,方法区只是一个逻辑分区,而元空间是具体实现。所以类的元数据是存放在元空间的,逻辑上属于方法区。

    2019-07-16 10:41:22