1.是什么
(1) 基本概念:可运行 Java 代码的非真实计算机 ,包括一套字节码指令集
、一组寄存器
、一个栈
、一个垃圾回器
,堆
和一个存储方法域
。它运行在操作系统之上,与硬件没有直接的交互。
(2) 运行过程:Java 源文件.java
通过编译器javac
,能够生产相应的.class
字节码文件,而字节码文件又通过 Java 虚拟机中的解释器,编译成特定机器上的机器码。
不同平台的解释器不同,但是编译的过程是相同的,这就是所谓的一次编译到处执行
跨平台 。一个程序开始运行java -jar xxx.jar
虚拟机就会进行实例化,多个程序启动就会存在多个虚拟机实例。程序退出或者关闭,则虚拟机实例被销毁,多个虚拟机实例之间数据不能共享。
2.内存结构
内存是非常重要的系统资源,是硬盘和 CPU 的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM 内存布局规定了 Java 程序在运行过程中的内存申请、分配、管理策略,保证了它的高效稳定运行。不同的 JVM 对于内存的划分方式和管理机制存在着部分差异。
JVM 运行时管理的内存就是虚拟机内存
它会把这些内存分配成不同的区域, JVM 利用到的没有直接管理的物理内存就是本地内存
,这两种内存有一定的区别:
- 虚拟机内存:受虚拟机内存参数控制
-Xmx 设置; Runtime.getRuntime().maxMemory() 进行查看
,超过参数设置的大小时就会报OOM。 - 本地内存:本地内存不受虚拟机内存参数的限制,只受物理内存容量的限制,虽然不受参数的限制,但是如果内存的占用超出物理内存的大小,同样也会报OOM。
JVM 定义了若干种程序运行期间会使用到的运行时数据区内存区域
,其中有一些会随着虚拟机启动而创建,随着虚拟机退出或关闭而销毁。另外一些则是与线程一一对应的,这些与线程一一对应的数据区域会随着线程开始和结束而创建和销毁。
- 线程共享:方法区、堆、堆外内存(永久代或元空间、代码缓存)
- 线程私有:栈、程序计数器、本地方法栈
下图是 JVM 整体架构,中间部分就是它定义的各种运行时数据区域:
3.堆内存空间
Heap Area 堆
是 JVM 内存中最大的一块,被垃圾收集器管理也被所有线程共享。主要存放对象实例,由于 JVM 的发展,堆中也多了许多东西:
堆可以是处于物理上不连续的内存空间中,只要逻辑上是连续的即可,像磁盘空间一样,既可以是固定大小,也可以是可扩展的(通过参数-Xmx和-Xms
设定),堆在无法扩展或者无法分配内存时会报 OOM 也就是堆内存耗尽
,堆内存逻辑上
被划分成 3️⃣ 个区域(分代的唯一理由就是优化 GC 性能):
- 新生区(年轻代):新对象和没达到一定年龄的对象都在新生区【占堆的1/3】。
- 养老区(老年代):被长时间使用的 MinorGC 未回收的对象【占堆的2/3】。
- 元空间(JDK8之前叫永久区):像一些方法中操作的临时对象等,JDK8之前是占用JVM内存,JDK8使用本地内存
这也是为什么在限制了JVM堆内存之后程序处理大量数据时电脑内存还是被大量占用的原因
。
Visual VM 的插件 Visual GC 绘制的 Spaces:
3.1 新生区 (New Space/Young Generation)
新生区是所有新对象存储的地方,被分为 3️⃣ 个区域:伊甸区(Eden Space
)和两个幸存区(Survivor Space
,被称为 from survivor/to survivor 或 s0/s1),默认比例是8:1:1
。
刚被new出来的对象都是存放在伊甸区【如果新创建的对象占用内存很大,超过了-XX:PetenureSizeThreshold 则直接分配到养老区
】,当前空间用完时,程序又需要创建对象,JVM 的垃圾回收器将对伊甸区进行垃圾回收(这种垃圾收集称为MinorGC
),销毁伊甸区中不再被其他对象所引用的对象并将剩余对象移动到s0
区。若s0
区也满了,再对该区进行垃圾回收,然后移动到s1
区。那如果s1
区也满了呢?再移动到养老区。若养老区也满了,那么这个时候将产生 MajorGC(FullGC),进行养老区的内存清理。若养老区执行了 FullGC 之后发现依然无法进行对象的保存,就会产生OOM。
MinorGC
的过程为(复制->清空->互换):
【问题:为什么要将新生区分为三个区域?
】由于年轻代的垃圾回收算法,(复制算法)设置两个Survivor区最大的好处就是解决了碎片化,刚刚新建的对象在Eden中,经历一次Minor GC,Eden中的存活对象就会被移动到S0,Eden被清空;等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制送入S1区(这个过程非常重要,因为这种复制算法保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生),接着新对象继续分配在Eden区和另外那块开始被使用的Survivor区,然后始终保持一块Survivor区是空着的,就这样一直循环使用这三块内存区域。
JVM 会给new出来的对象定义一个对象年轻计数器
每次的 MinorGC 对象的年龄就会+1
达到老年的标准-XX:MaxTenuringThreshold
【默认值为15】则复制到养老区:
3.2 养老区(Tenure Space/Old Generation)
养老区主要存放应用程序中生命周期长的内存对象。老年代的对象比较稳定,所以Major GC
不会频繁执行。在进行Major GC
前一般都先进行了一次Minor GC
,使得有新生代的对象晋升入老年代,导致空间不够用时才触发。
大对象直接进入养老区(大对象是指需要大量连续内存空间的对象,超过了-XX:PetenureSizeThreshold
)。这样做的目的是避免在 Eden 区和两个 Survivor 区之间发生大量的内存拷贝。当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次Major GC
进行垃圾回收腾出空间。
Major GC
采用标记清除算法:首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。因为要扫描再回收所以Major GC
的耗时比较长。Major GC
会产生内存碎片,为了减少内存损耗,一般需要进行合并或者标记出来方便下次直接分配。当养老区也满了装不下的时候,就会抛出OOM。
3.3 元空间(Meta Space/Permanent Generation)
不管是 JDK8 之前的永久代,还是 JDK8 及以后的元空间,都可以看作是 JVM 规范中方法区的实现。虽然 JVM 规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 Non-Heap(非堆),目的应该是与 Java 堆区分开。
永久存储区是一个常驻内存区域,用于存放 JDK 自身所携带的 Class、Interface 的元数据,也就是说它存储的是运行环境必须的类信息,被装载进此区域的数据是不会被垃圾回收器回收掉的,关闭 JVM 才会释放此区域所占用的内存。
如果出现java.lang.OutOfMemoryError: PermGen space
,说明是 JVM 对永久代 Perm 内存设置不够。一般出现这种情况,都是程序启动需要加载大量的第三方 jar 包。例如:在一个Tomcat下部署了太多的应用。或者大量动态反射生成的类不断被加载,最终导致 Perm 区被占满。
版本 | 永久代 | 常量池 |
JDK6及之前 | 有永久代 | 在方法区 |
JDK7 | 有永久代,已逐步“去永久代” | 在堆 |
JDK8及之后 | 无永久代 | 在元空间 |
4.堆内存参数
Java 堆用于存储 Java 对象实例,那么堆的大小在 JVM 启动的时候就确定了,我们可以通过 -Xmx
和 -Xms
来设定
-Xms
堆的起始内存,默认情况下为服务器内存的1/64
,等价于-XX:InitialHeapSize
-Xmx
堆的最大内存,默认情况下为服务器内存的1/4
,等价于-XX:MaxHeapSize
如果堆的内存大小超过 -Xms
设定的最大内存, 就会抛出OOM。通常会将 -Xmx
和 -Xms
两个参数配置为相同的值,其目的是为了能够在垃圾回收机制清理完堆区后不再需要重新分隔计算堆的大小,从而提高性能。
可以通过代码获取到我们的设置值,当然也可以模拟 OOM:
public static void main(String[] args) { // JVM 堆大小 long totalMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024; // JVM 堆的最大内存 long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024; System.out.println("-Xms : " + totalMemory + "M"); System.out.println("-Xmx : " + maxMemory + "M"); // 反向计算计算间内存 System.out.println("系统内存大小:" + totalMemory * 64 / 1024 + "G"); System.out.println("系统内存大小:" + maxMemory * 4 / 1024 + "G"); }
5.堆内存分配
在默认不配置 JVM 堆内存大小的情况下,JVM 根据默认值来配置当前内存大小:
- 默认情况下新生区和养老区的比例是
1:2
,可以通过–XX:NewRatio
来配置。 - 新生代中的
Eden:From Survivor:To Survivor
的比例是8:1:1
,可以通过-XX:SurvivorRatio
来配置。 - 若在JDK7中开启了
-XX:+UseAdaptiveSizePolicy
,JVM 会动态调整 JVM 堆中各个区域的大小以及进入老年代的年龄,此时–XX:NewRatio
和-XX:SurvivorRatio
将会失效;而 JDK8 是默认开启-XX:+UseAdaptiveSizePolicy
在 JDK8中,不要随意关闭-XX:+UseAdaptiveSizePolicy
,除非对堆内存的划分有明确的规划。
每次 GC 后都会重新计算 Eden、From Survivor、To Survivor 的大小计算依据是 GC 过程中统计的GC时间、吞吐量、内存占用量:
# JDK8 java -XX:+PrintFlagsFinal -version | grep HeapSize uintx ErgoHeapSizeLimit = 0 {product} uintx HeapSizePerGCThread = 87241520 {product} uintx InitialHeapSize := 29360128 {product} uintx LargePageHeapSizeThreshold = 134217728 {product} uintx MaxHeapSize := 459276288 {product} java version "1.8.0_251" Java(TM) SE Runtime Environment (build 1.8.0_251-b08) Java HotSpot(TM) 64-Bit Server VM (build 25.251-b08, mixed mode) # 查看进程的堆信息 jmap -heap 进程号
6.堆内存回收(垃圾回收)
JVM 在进行 GC 时,并非每次都对堆内存(新生区、养老区、方法区)区域一起回收的,大部分时候回收的都是指新生代。针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大类:
(1)整堆收集(FullGC):收集整个 Java 堆和方法区的垃圾。
(2)部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为:
新生代收集(MinorGC/YoungGC):只是新生代的垃圾收集。
老年代收集(MajorGC/OldGC):只是老年代的垃圾收集。
目前只有 G1 GC 会有这种行为
目前,只有 CMS GC 会有单独收集老年代的行为
很多时候 MajorGC 会和 FullGC 混合使用,需要具体分辨是老年代回收还是整堆回收
混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集
7.TLAB(Thread Local Allocation Buffer)
从内存模型而不是垃圾回收的角度,对 Eden 区域继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为快速分配策略。为什么要有 TLAB:
- 堆区是线程共享的,任何线程都可以访问到堆区中的共享数据
- 由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
- 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度
尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选。在程序中,可以通过 -XX:UseTLAB
设置是否开启 TLAB 空间。默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,我们可以通过 -XX:TLABWasteTargetPercent
设置 TLAB 空间所占用 Eden 空间的百分比大小。一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。
8.堆是分配对象存储的唯一选择吗
随着 JIT 编译期的发展和逃逸分析技术的逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。 ——《深入理解 Java 虚拟机》
逃逸分析(Escape Analysis)是目前 Java 虚拟机中比较前沿的优化技术。这是一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot 编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。逃逸分析的基本行为就是分析对象动态作用域:
- 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
- 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中,称为方法逃逸。
例如:
public static StringBuffer craeteStringBuffer(String s1, String s2) { StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); return sb; }
StringBuffer sb
是一个方法内部变量,上述代码中直接将sb返回,这样这个 StringBuffer 有可能被其他方法所改变,这样它的作用域就不只是在方法内部,虽然它是一个局部变量,称其逃逸到了方法外部。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
上述代码如果想要 StringBuffer sb
不逃出方法,可以这样写:
public static String createStringBuffer(String s1, String s2) { StringBuffer sb = new StringBuffer(); sb.append(s1); sb.append(s2); return sb.toString(); }
不直接返回 StringBuffer,那么 StringBuffer 将不会逃逸出方法。
参数设置:
- 在 JDK 6u23版本之后,HotSpot 中默认就已经开启了逃逸分析
- 如果使用较早版本,可以通过
-XX"+DoEscapeAnalysis
显式开启
开发中使用局部变量,就不要在方法外定义。使用逃逸分析,编译器可以对代码做优化:
- 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配
- 同步省略:如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
- 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而存储在 CPU 寄存器
JIT 编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无需进行垃圾回收了。
常见栈上分配的场景:成员变量赋值、方法返回值、实例引用传递
8.1 代码优化之同步省略(消除)
- 线程同步的代价是相当高的,同步的后果是降低并发性和性能
- 在动态编译同步块的时候,JIT 编译器可以借助逃逸分析来判断同步块所使用的锁对象是否能够被一个线程访问而没有被发布到其他线程。如果没有,那么 JIT 编译器在编译这个同步块的时候就会取消对这个代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫做同步省略,也叫锁消除。
public void keep() { Object keeper = new Object(); synchronized(keeper) { System.out.println(keeper); } }
如上代码,代码中对 keeper 这个对象进行加锁,但是 keeper 对象的生命周期只在 keep()
方法中,并不会被其他线程所访问到,所以在 JIT编译阶段就会被优化掉。优化成:
public void keep() { Object keeper = new Object(); System.out.println(keeper); }
8.2 代码优化之标量替换
标量(Scalar)是指一个无法再分解成更小的数据的数据。Java 中的原始数据类型就是标量。
相对的,那些的还可以分解的数据叫做聚合量(Aggregate),Java 中的对象就是聚合量,因为其还可以分解成其他聚合量和标量。
在 JIT 阶段,通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而会将该对象成员变量分解若干个被这个方法使用的成员变量所代替。这些代替的成员变量在栈帧或寄存器上分配空间。这个过程就是标量替换。
通过 -XX:+EliminateAllocations
可以开启标量替换,-XX:+PrintEliminateAllocations
查看标量替换情况。
public static void main(String[] args) { alloc(); } private static void alloc() { Point point = new Point(1,2); System.out.println("point.x="+point.x+"; point.y="+point.y); } class Point{ private int x; private int y; }
以上代码中,point 对象并没有逃逸出alloc()
方法,并且 point 对象是可以拆解成标量的。那么,JIT 就不会直接创建 Point 对象,而是直接使用两个标量 int x ,int y 来替代 Point 对象。
private static void alloc() { int x = 1; int y = 2; System.out.println("point.x="+x+"; point.y="+y); }
8.3 代码优化之栈上分配
通过 JVM 内存分配可以知道 JAVA 中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠 GC 进行回收内存,如果对象数量较多的时候,会给 GC 带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM 通过逃逸分析确定该对象不会被外部访问。那就通过标量替换将该对象分解在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。
**其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。**一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。