20讲磨⼑不误砍柴⼯:欲知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个分区具体是怎么实现的呢?我们⼀⼀分析。
堆(Heap)
堆是JVM内存中最⼤的⼀块内存空间,该内存被所有线程共享,⼏乎所有对象和数组都被分配到了堆内存中。堆被划分为新⽣代和⽼年代,新⽣代⼜被进⼀步划分为Eden和Survivor区,最后Survivor由From Survivor和To Survivor组成。
在Java6版本中,永久代在⾮堆内存区;到了Java7版本,永久代的静态变量和运⾏时常量池被合并到了堆中;⽽到了Java8, 永久代被元空间取代了。 结构如下图所示:
程序计数器(Program Counter Register)
程序计数器是⼀块很⼩的内存空间,主要⽤来记录各个线程执⾏的字节码的地址,例如,分⽀、循环、跳转、异常、线程恢复等都依赖于计数器。
由于Java是多线程语⾔,当执⾏的线程数量超过CPU数量时,线程之间会根据时间⽚轮询争夺CPU资源。如果⼀个线程的时间⽚⽤完了,或者是其它原因导致这个线程的CPU资源被提前抢夺,那么这个退出的线程就需要单独的⼀个程序计数器,来记录下⼀条运⾏的指令。
⽅法区(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的整个处理过程如下:
JVM向操作系统申请内存,JVM第⼀步就是通过配置参数或者默认配置参数向操作系统申请内存空间,根据内存⼤⼩找到具体的内存分配表,然后把内存段的起始地址和终⽌地址分配给JVM,接下来JVM就进⾏内部分配。
JVM获得内存空间后,会根据配置参数分配堆、栈以及⽅法区的内存⼤⼩。
class⽂件加载、验证、准备以及解析,其中准备阶段会为类的静态变量分配内存,初始化为系统的初始值(这部分我在第
21讲还会详细介绍)。
4.完成上⼀个步骤后,将会进⾏最后⼀个初始化阶段。在这个阶段中,JVM⾸先会执⾏构造器
⽂件被编译成.class ⽂件时,收集所有类的初始化代码,包括静态变量赋值语句、静态代码块、静态⽅法,收集在⼀起成为
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内存模型中的哪块区域呢?
期待在留⾔区看到你的答案。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他⼀起讨论。
精选留⾔
Xiao
⽼师,这⼉其实应该说JVM内存结构更合适!JVM内存模型是⼀种规范,和JVM内存结构不是⼀个概念。其次,元空间,在Ja
va8,不是在堆内分配的,它的⼤⼩是依赖于本地内存⼤⼩!
2019-07-09 09:31
作者回复
感谢Xiao同学的提醒。
我想你说的内存模型应该是指Java内存模型(JMM)吧。这⾥的JVM内存模型跟Java内存模型是不⼀样的,这⾥的JVM内存模型和和内存结构是⼀个意思。
元空间是分配的本地内存,⽂中开始描述不清楚(已纠正),但后⾯有明确说明。
2019-07-09 15:04
我⼜不乱来
String a=”b”应该会放在字符串常量池中。
String c= new String(“b”) ⾸先应该放在 堆中⼀份,再在常量池中放⼀份。但是常量池中有b了。第⼀次留⾔。不知道理解的对不对。超哥
2019-07-09 07:42
作者回复
正确
2019-07-09 09:20
张学磊
String a=”b”可能创建⼀个对象或者不创建对象,如果”b”这个字符串在常量池⾥不存在会在常量池创建⼀个String对象”b”,如果已经存在则a直接reference to这个常量池⾥的对象;
String c= new String(“b”)⾄少创建⼀个对象,也可能两个,因为⽤到new关键字,会在堆内在创建⼀个的String对象,它的值是”b”。同时,如果”b”这个字符串在常量池⾥不存在,会在常量池创建这个⼀个String对象”b”。
2019-07-09 14:00
作者回复
对的
2019-07-10 09:51
Liam
请教⼀个问题,所以1.8开始,⽅法区是堆的⼀部分吗?也即是说,⽅法区的⼤⼩受限于堆
2019-07-09 08:08
作者回复
⽅法区不是堆的⼀部分,⽅法区和堆存在交集。⽅法区的静态变量和运⾏时常量池存放在堆中,但类的元信息等还是存放在了本地内存中。
2019-07-09 09:25
⿊夜⾥的猫
字符串常量不是在java8中已经被放⼊到堆中了吗,应该不在⽅法区中了,但是看到⽼师的图中还在⽅法区中
2019-07-09 19:50
作者回复
⽅法区是⼀个规范,并不是⼀个物理空间,我们这⾥说的字符串常量放在堆内存空间中,是指实际的物理空间。
2019-07-12 09:43
听⾬
元空间不是本地内存吗,⽼师说的元空间移⼊堆内存是什么意思呀,不理解,是元空间属于堆内存的⼀部分吗?
2019-07-09 12:47
作者回复
⽽到了 Java8,永久代被元空间取代了,元空间存储静态变量…
以上这句话描述不准确。将元空间去掉。元空间是使⽤的本地内存,在后⾯讲述到了:“并且元空间的存储位置是本地内存”
2019-07-09 14:46
超威⼂
其实常量池中是不会存储具体对象的吧,也是引⽤,所以说new String的话会现在常量池中去寻找,存在直接由常量池中的引
⽤指向堆中对象,不存在直接开辟新对象?
2019-07-09 09:22
作者回复
字符串常量存储在了常量池,引⽤在运⾏时存放在了栈中。new String(“”)是会创建⼀个新对象的,可以查看⼀下构造函数: public String(String original) {
this.value = original.value; this.hash = original.hash;
}
2019-07-10 09:39
TerryGoForIt
⽼师您好,我想问⼀下,深⼊理解 JIT 放到下⼀节了嘛?我看课程⽬录 JIT 是在 JMM 之前哇。
2019-07-09 07:29
作者回复
是的,调换下位置⽅便更好理解JIT,因为JIT⽤到了JVM内存的知识点。声明下,这⾥不是JMM,JMM是Java Memory Model
,⽽我们这节讲的是JVM的内存模型(Java Virtual Machine Structure)。
2019-07-09 09:19
⼤仙
⽼师,你的图中⽅法区初始化的时候两个字段名称都写成了是 MAN_SEX_TYPE,应该是⼀个:MAN_SEX_TYPE、WOMAN
_SEX_TYPE
2019-07-16 23:11
作者回复
对的,谢谢这位同学的提醒
2019-07-17 09:16
⽂灏
请教⼀下,1.8中类的元数据是放在元数据区还是⽅法区呢?看得有点晕
2019-07-15 21:24
作者回复
元空间是属于⽅法区的,⽅法区只是⼀个逻辑分区,⽽元空间是具体实现。所以类的元数据是存放在元空间的,逻辑上属于⽅
法区。
2019-07-16 10:41
东⽅奇骥
⽼师,问⼀下,1.8静态变量和常量存储在的堆⾥⾯,那元空间⾥是什么?⽂中说之前永久带类的数据存储在了元空间,不是很理解,
2019-07-12 12:54
作者回复
元空间主要存储类的⼀些信息,包括⽅法、字段、类等描述类信息。
2019-07-17 11:19
晓杰
创建⼀个线程,就会在虚拟机中申请⼀个栈帧,这句话有问题吧
应该是创建⼀个线程,会创建⼀个栈,然后⽅法调⽤⼀次,就会申请⼀个栈帧吧
2019-07-11 15:48
作者回复
对的,这⾥是申请⼀个线程栈。
2019-07-12 11:26
Liam
看了下留⾔,我再和⽼师确认下,所以⽅法区实际上是在本地内存即堆外内存分配的吗
2019-07-11 07:54
作者回复
对的
2019-07-12 10:42
Cain
常量池在哪个区?堆区?栈区?⽅法区?静态区?⽅法区,静态区他俩是什么关系?
2019-07-10 19:30
作者回复
在逻辑空间是属于⽅法区。堆、栈、⽅法区等,这些是⼀种规范,是逻辑上的分区。
在物理空间中,常量池是存储在堆内存空间的。
2019-07-12 10:05
⼩橙橙
⽼师好,⽂中说“元空间(class metadata)代替了之前的永久代”,但元空间在Java1.8版本中的作⽤是什么呢?
2019-07-10 17:04
作者回复
跟之前永久代的作⽤差不多,例如,存储类信息,只是实现的⽅式和存储的位置不⼀样。
2019-07-12 10:04
苏志辉
图中⻩⾊的部分⽅法区属于堆吧,还有就是我看先调⽤的是print然后是sayhello
2019-07-10 09:27
作者回复
有部分是属于堆空间。对的,这⾥不强调调⽤顺序。
2019-07-12 10:03
⼀路奔跑
刘⽼师,前⾯章节在评论区有指出⽂章内容描述不准确的地⽅,修改了吗?如果修改了我们就以⽂章为准,如果没有,我们读
⽂章时就注意下!!
2019-07-10 08:55
作者回复
已修正,以⽂章为准
2019-07-10 09:26
咸⻥
⽼师 有个问题 就是上边讲的JVMCase 的sayHello⽅法⼊栈 ⾮静态⽅法⼊栈 那静态⽅法呢
2019-07-10 08:11
作者回复
都有⼊栈出栈
2019-07-12 10:01
明翼
⽼师您的这些知识是怎么学的啊?厉害
2019-07-10 06:51
歪曲⼂
⽆⽤代码剔除 ⽅法内联 逃逸分析等特性下 new String(“b”) 不会创建对象的
2019-07-10 00:50
作者回复
哈哈,厉害了。这些知识点在下⼀讲中将会出现。这⾥是在排除这些情况下的问答题。
2019-07-10 09:33