本文转载自微信公众号「大鱼仙人」,作者大鱼 。转载本文请联系大鱼仙人公众号。
说到写bug,我们每天都在用Java实现着各种需求,我们实现的Java程序每天都运行在每个机器的虚拟机上,但是你了解你写的代码的具体存储位置吗
说实话,这个东西,在我刚开始学Java的时候,我听到JVM虚拟机这个名词的时候,我的感觉是这个样子的(惭愧
你们肯定也会有些疑问吧,平时写的代码每一部分都是存储在哪里的?是的,没错,我的内心就像拖着下巴的那位,除了,模样,emmm...
虽然现在也不是多么的精通,但是比之前好太多了,不是涉及很底层的东西也算是了解一些,当然真要是问我各种涉及细节,毫不谦虚的说,以我的水平,我可能只会阿巴阿巴(逃
如果大家对更深入的JVM感兴趣,可以和JVM大神R大这种多去沟通沟通
是的,没错,其实我这个文章算是扫盲文章,但是在扫盲文章的基础上说的更细一点,更多一点,我也会给大家抛出一些面试官爱问的问题,并且帮大家解答,所以大家请尽情读下去,肯定会让你有所收获
大家觉得不错的点个关注,大家一起探讨、一起学习、一起进步
JVM内存结构
JVM内存布局,先给大家上个图
如果你是读过JVM文章的养鱼仔的话,那你肯定看过上面类似的图,我在给大家放一张,大家在熟悉一遍,看过的回一下,没看过的混个脸熟
JVM内存主要分为堆、虚拟机栈、本地方法栈、方法区、程序计数器等,堆是虚拟机内存占据最大的一部分,堆的目的就是盛放大量的对象实例的;虚拟机栈对应的是方法的执行过程,本地方法栈是用来调用本地方法的执行过程;方法区就是用来存储存储类信息、常量、静态变量的数据,是线程共享的数据;程序计数器,就是存储着线程下一条将要执行的指令
每个区域都有其特定的功能,就像是一个企业,一个工作室,每个人发挥着自己的长处,各司其职
走着吧,各位养鱼仔(我是鱼),一起来瞧瞧每一部分的具体的细节以及面试官爱问的问题
虚拟机堆
Java堆是垃圾收集器管理的主要地方,因此很多的时候也被称为GC堆,Java堆还可以分为年轻代和老年代,年轻代又可以分为Eden空间、From Survivor空间、To Survivor空间,默认是8:1:1的比例
根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样
在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制);如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
打断一下,Java堆的区域都是线程共享的吗?
当你听到这个问题的时候,你首先想到的是什么呢?
let me tell you,面试官其实问这个的时候就是在看你对堆的了解程度,你只知道是用来放对象实例的,那面试官对你表现觉得不算非常满意;但是如果你知道TLAB,并且知道它的原理和问题,那面试官就会觉得:这小伙子不一般,我得再多深入了解了解,可以考虑当我的好助手
首先,你得肯定回答,没错,堆是全局共享的,但是会存在一些问题,那就是多个线程在堆上同时申请空间,如果在并发的场景中,两个线程先后把对象引用指向了同一个内存区域,那可能就会出现问题;为了解决这个问题呢,就得进行同步控制,说到同步控制,就会影响到效率
就拿Hotspot来举例子,它的解决方案是每个线程在堆中都预先分配一小块内存,然后再给对象分配内存的时候,先在这块“私有内存”进行分配,这块用完之后再去分配新的“私有内存”,这就是TLAB分配
你也看到了,我加引号了,它并不是真正意义上的私有,而是表面上的私有;它是从堆内存划分出来的,有了TLAB技术,堆内存并不是完完全全的线程共享,每个线程在初始化的时候都会去内存中申请一块TLAB
切记:并不是TLAB区域的内存其它线程完全无法访问,其它线程也是可以读取的,只不过无法在这个区域分配内存而已
说到这的时候,也给面试官一个眼神,说明我的干货还没完,我还能继续吹
难道TLAB很完美吗?所谓,金无足赤人无完人,肯定有他的问题所在
我们知道TLAB是线程特有的,它的内存区域不是很大,所以会出现一些不够用的情况,比如一个线程的TLAB的空间有100KB,其中已经使用了80KB,如果还需要再分配一个30KB的对象,则无法直接在TLAB上分配了,这种情况有两种解决办法
- 直接在堆中分配
- 废弃当前TLAB,重新申请TLAB空间再次进行内存分配
其实两种各有利弊,第一种的缺点就是存在一种极端情况,TLAB只剩下1KB,就会导致后续的分配可能大多数对象都需要直接在堆中分配;第二种的就是可能会出现频繁的废弃TLAB、频繁申请TLAB的情况
为了解决这两个方案存在的问题,虚拟机定义了一个refill_waste的值,这个值可以翻译为“最大浪费空间”。当请求分配的内存大于refill_waste的时候,会选择在堆内存中分配。若小于refill_waste值,则会废弃当前TLAB,重新创建TLAB进行对象内存分配
那你刚刚说的,几乎所有对象实例都存储在这里,是还有例外吗?能详细解释下吗?
是的,亲爱的面试官,Java对象实例和数组元素不一定都是在堆上分配内存,满足特定的条件的时候,它们可以在栈上分配内存
面试官微微一笑,什么情况呢?
亲爱的面试官,是这样子的,JVM中的Java JIT编译器有两个优化,叫做逃逸分析和标量替换;
逃逸分析,听着有点意思,逃,谁逃,什么时候逃,往哪里逃?
中文维基上对逃逸分析的描述挺准确的,摘录如下:
在编译程序优化理论中,逃逸分析是一种确定指针动态范围的方法——分析在程序的哪些地方可以访问到指针。当一个变量(或对象)在子程序中被分配时,一个指向变量的指针可能逃逸到其它执行线程中,或是返回到调用者子程序。
大鱼白话文版本:
一个子程序分配了一个对象并且返回了该对象的指针,那么这个对象在整个程序中被访问的地方无法确定,任何调用这个子程序的都可以拿到这个对象的位置,并且调用这个对象,遂,对象逃之;
若指针存储在全局变量或者其它数据结构中,全局变量也可以在子程序之外被访问到,遂,对象逃之;
若未逃之,则可将方法变量和对象分配到栈上,方法执行完之后自动销毁,不需要垃圾回收的介入,提高系统的性能
简洁版:
逃逸分析通过分析对象引用的作用域,来决定对象的分配地方(堆 or 栈)
我们一起来看个例子
- public StringBuilder getBuilder1(String a, String b) {
- StringBuilder builder = new StringBuilder(a);
- builder.append(b);
- // builder通过方法返回值逃逸到外部
- return builder;
- }
- public String getBuilder2(String a, String b) {
- StringBuilder builder = new StringBuilder(a);
- builder.append(b);
- // builder范围维持在方法内部,未逃逸
- return builder.toString();
getBuilder1中的builder对象会通过方法返回值逃逸到方法的外部,而反观getBuilder2中的builder对象则不会溢出去,作用域只会在方法内部,toString方法会new一个String用来返回,所以没有逃逸
如果把堆内存限制得小一点(比如加上-Xms10m -Xmx10m),关闭逃逸分析还会造成频繁的GC,开启逃逸分析就没有这种情况,说明逃逸分析确实降低了堆内存的压力
逃逸分析了之后,就可以直接降低堆内存的压力吗?(你刚刚说的那个标量替换是什么)
但是,逃逸分析只是栈上内存分配的前提,接下来还需要进行标量替换才能真正实现。标量替换用话不太好说明,直接来看例子吧,形象生动
- public static void main(String[] args) throws Exception {
- long start = System.currentTimeMillis();
- for (int i = 0; i < 10000; i++) {
- allocate();
- }
- System.out.println((System.currentTimeMillis() - start) + " ms");
- Thread.sleep(10000);
- }
- public static void allocate() {
- MyObject myObject = new MyObject(2019, 2019.0);
- }
- public static class MyObject {
- int a;
- double b;
- MyObject(int a, double b) {
- this.a = a;
- this.b = b;
- }
- }
标量,就是指JVM中无法再细分的数据,比如int、long、reference等。相对地,能够再细分的数据叫做聚合量
Java虚拟机中的原始数据类型(int,long等数值类型以及reference类型等)都不能再进一步分解,它们就可以称为标量。相对的,如果一个数据可以继续分解,那它称为聚合量,Java中最典型的聚合量是对象
如果逃逸分析证明一个对象不会被外部访问,并且这个对象是可分解的,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。拆散后的变量便可以被单独分析与优化,可以各自分别在栈帧或寄存器上分配空间,原本的对象就无需整体分配空间了
仍然考虑上面的例子,MyObject就是一个聚合量,因为它由两个标量a、b组成。通过逃逸分析,JVM会发现myObject没有逃逸出allocate()方法的作用域,标量替换过程就会将myObject直接拆解成a和b,也就是变成了:
- static void allocate() {
- int a = 2019;
- double b = 2019.0;
- }
可见,对象的分配完全被消灭了,而int、double都是基本数据类型,直接在栈上分配就可以了。所以,在对象不逃逸出作用域并且能够分解为纯标量表示时,对象就可以在栈上分配
除了这些之后,你还知道哪些优化吗?
emmm,先思索一下(即使知道,也要稍加思考!
除此之外,JVM还有一个同步消除(锁消除):锁消除是Java虚拟机在JIT编译是,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。
锁消除基于分析逃逸基础之上,开启锁消除必须开启逃逸分析
线程同步本身比较耗,如果确定一个对象不会逃逸出线程,无法被其它线程访问到,那该对象的读写就不会存在竞争,对这个变量的同步措施就可以消除掉。单线程中是没有锁竞争。(锁和锁块内的对象不会逃逸出线程就可以把这个同步块取消)
- public synchronized String append(String str1, String str2) {
- StringBuffer sBuf = new StringBuffer();
- // append方法是同步操作
- sBuf.append(str1);
- sBuf.append(str2);
- return sBuf.toString();
- }
从源码中可以看出,append方法用了synchronized关键词,它是线程安全的。但我们可能仅在线程内部把StringBuffer当作局部变量使用
这时我们可以通过编译器将其优化,将锁消除,前提是java必须运行在server模式,server模式会比client模式作更多的优化,同时必须开启逃逸分析
说一说刚刚说的这些的参数吗
我个乖乖兔,这我哪记得,不过得亏我昨天刚读了大鱼的文章,顺便学习了下
逃逸分析:-XX:+DoEscapeAnalysis开启逃逸分析(jdk1.8默认开启,其它版本未测试);-XX:-DoEscapeAnalysis 关闭逃逸分析
同步消除:-XX:+EliminateLocks开启锁消除(jdk1.8默认开启,其它版本未测试);-XX:-EliminateLocks 关闭锁消除
标量替换:-XX:+EliminateAllocations开启标量替换(jdk1.8默认开启,其它版本未测试);-XX:-EliminateAllocations 关闭标量替换
那你平时是用哪些参数优化内存的?
一般我个人接触到的有两类参数:内存调整参数、垃圾收集器调整参数
内存调整参数:-Xmx堆内存最大值;-Xms堆内存最小值;-Xmn堆新生代的大小;-Xss设置线程栈的大小;-XX:NewRatio指定堆中的老年代和新生代的大小比例, 不过使用CMS收集器的时候这个参数会失效
关于方法区的参数,在JDK8之前,用-XX:PermSize和-XX:MaxPermSize来分别设置方法区的最小值和最大值;JDK8以及之后不再使用这个参数来设置方法区了,改为-XX:MeatspaceSize和-XX:MaxMetaspaceSize来设置方法区的大小了,Max参数主要就是防止某些情况导致Metaspace无限的使用本地内存,若超过设定值就会触发Full GC,所以需要根据系统内存大小来动态的改变此值
垃圾收集器的调整参数我就不举例子了,垃圾收集器调整参数就是设置JVM的垃圾收集器或者调整收集器的一些优化参数,说实话大鱼也不没那么了解,这种参数我一般都是用到的时候去查资料,也没啥必要了解那么细,专业人员除外
你刚刚说了堆内存中有个8:1:1,出于什么考虑这样设计的呢
有的对象朝生夕死,有的对象可能会活很久很久,有的对象很小,有的对象可能会很大,每个对象的特点不一样,分配的堆内存地方不一样,也就对应着不同的回收策略以及垃圾回收器,年轻代就是存放那种使用完就立马回收的对象,而老年代则用来存放那些长期驻留在内存中的对象
其实说白了,就是根据多种对象的特点来设计出多种了回收策略,而对于整块内存使用一种回收策略是不友好的,所以根据对象的特点来将堆内存拆分开,然后对于每块内存采用不同的回收策略
虚拟机栈和本地内存栈
Java虚拟机栈属于线程私有的,生命周期和线程相同;虚拟机栈是Java方法执行的内存模型,描述的方法的执行过程;每个方法被执行的时候都会同时创建一个栈帧结构,用于存储局部变量表、操作数栈、动态链接、方法出口等信息,栈里面会包含很多的
栈帧,可以认为每一个方法的调用直到执行完成对应这一个栈帧的入栈和出栈的过程
虚拟机栈的栈帧里面都包含什么呢?
主要是包含局部变量表、操作数栈、动态链接、方法出口这些,接着我们来看下每一部分的作用
这些大家不需要死记硬背的哦,需要大家理解记忆,最重要的是理解每一部分的作用,下面可能第一次接触的会比较枯燥,keep
局部变量表:存放了编译期可知的各种基本数据类型、对象引用(reference类型,它不等同于对象本身,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)。
操作数栈:一个后进先出的操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为maxstack的值。
动态链接:在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(symbolic Reference)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
方法出口:存放调用该方法的pc寄存器的值。一个方法的结束,有两种方式:正常执行完成、出现未处理的异常,非正常退出
无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
栈的深度问题
在Java虚拟机规范中,对这个区域规定了两种异常状况:
如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),当扩展时无法申请到足够的内存时会抛出OutOfMemoryError异常。
那本地方法栈是干什么的?
本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。
虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。
Java虚拟机栈于管理Java方法的调用,而本地方法栈(Native Method Stack)用于管理本地方法的调用。本地方法栈,也是线程私有的。
方法区
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
方法区在原来被习惯性的称之为永久代,但是在JDK1.8中永久代已经不存在了,存储的类信息、编译之后的代码数据都移到了元空间,而元空间并没有在堆中,而是直接占用的本地内存
元空间和永久代本质是类似的,其实都是对JVM规范中的方法区的实现,元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制
程序计数器
程序计数器啊,听名字其实就知道了,主要作用就是计数的,但是这里的计数并不是计算数量,而是记下一条的字节码指令
程序计数器占一小块内存空间,就是当前线程的执行的字节码的行号指示器,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
那程序计数器是线程私有还是公有?
相信聪明的养鱼仔肯定已经猜到了,当然是私有的嘞
Java虚拟机多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址,如果正在执行的是Natvie方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
我爱总结
好了,今天就先聊到这了,天也不早了,你早点回家休息吧,好好准备准备明天下午来继续下一轮面试吧
好的,尊敬的面试官(逃
回到家之后我就拿出我的小本本一顿总结,跟着大鱼一起来看看吧,养鱼仔们
- 堆:线程共享,主要用于分配实例对象,但由于逃逸分析的存在也不是完全在堆上分配,可能在栈上分配;逃逸分析是个基础,标量替换和锁消除正是基础逃逸分析的优化;堆中还有个TLAB分配,属于线程私有,但又不是完全意义上的私有
- 栈:线程私有,虚拟机栈主要是用于Java方法的执行,每个栈帧对应一个方法的入栈和出栈,包含局部变量、操作数栈、动态链接和方法出口这些;本地方法栈则是用于执行本地方法的
- 方法区:线程共享,存放加载的类信息、常量、静态变量以及即时编译器编译之后的代码
- 程序计数器:线程私有,存放每个线程接下来要执行的指令