目录
一.了解JVM
1.1什么是JVM
1.2JRE/JDK/JVM
1.3JVM的整体结构
二.Java运行时数据区
2.1程序计数器(PC寄存器)
2.2Java虚拟机栈(Java Virtual Machine Stacks)
2.2.1栈帧的组成
2.2.2问题辨析
2.2.3逃逸分析
①栈上分配
②标量分析
③同步消除
2.2.4内存溢出
2.2.5线程运行诊断
2.3本地方法栈(Native Method Stacks)
2.4堆
2.4.1堆的演进
2.4.2堆的内容
对象实例
字符串常量池
静态变量
线程分配缓冲区(Thread Local Allocation Buffer)
2.4.3问题辨析
2.4.4内存溢出
2.4.5堆内存诊断
2.5方法区
2.5.1方法区和永久代以及元空间的关系
2.5.2为什么要将 永久代 替换为 元空间
2.5.3方法区的内容
①类元信息(Klass)
常量池表(Constant Pool Table)
②运行时常量池(Runtime Constant Pool)
2.5.4问题辨析
2.5.5方法区的特点
2.5.6设置方法区的大小
2.6再谈字符串常量池(StringTable)
2.6.1字面量创建字符串
2.6.2字符串变量拼接
2.6.3字符串常量拼接
2.6.4 intern方法
一.了解JVM
1.1什么是JVM
JVM是Java Virtual Machine(Java虚拟机)的缩写,是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟计算机功能来实现的,JVM屏蔽了与具体操作系统平台相关的信息,Java程序只需生成在Java虚拟机上运行的字节码,就可以在多种平台上不加修改的运行。JVM在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。
虚拟机可以分为系统虚拟机和程序虚拟机
- 系统虚拟机是一种虚拟化技术,它模拟整个计算机硬件环境,包括处理器、内存、存储和外部设备。它的主要目标是在单个物理计算机上同时运行多个操作系统。每个虚拟机都具有独立的操作系统和应用程序,就像在不同的物理计算机上运行一样。系统虚拟机的例子包括VMware、VirtualBox和Hyper-V。
- 程序虚拟机是一种虚拟化技术,它仅模拟计算机上的一个单独的应用程序运行环境,而不是整个操作系统。它的主要目标是提供一个独立的运行环境,使应用程序能够在不同的操作系统上运行而无需修改。程序虚拟机通常用于解决跨平台兼容性的问题,模拟一个应用程序的运行环境,使应用程序能够跨平台运行。常见的程序虚拟机包括Java虚拟机(JVM)。
1.2JRE/JDK/JVM
- JDK(Java Development Kit) 是整个JAVA的核心,包括了Java运行环境JRE(Java Runtime Envirnment),一堆Java工具(javac/java/jdb等)和Java基础的类库(即Java API)。
- JRE(Java Runtime Environment,Java运行环境), 是 Java 运行时环境。它是运行已编译 Java 程序所需的所有内容的集合,主要包括 Java 虚拟机(JVM)、Java 基础类库(Class Library)。JRE是Java运行环境,并不是一个开发环境,所以没有包含任何开发工具(如编译器和调试器)
- JVM(Java Virtual Mechinal)是JRE的一部分,叫做JAVA虚拟机,它是整个java实现跨平台的最核心的部分,负责解释执行并运行字节码文件(.class)。
1.3JVM的整体结构
- ClassLoader:Java 代码编译成二进制后,会经过类加载器,这样才能加载到 JVM 中运行。
- Method Area:类是放在方法区中。
- Heap:类的实例对象。
当类调用方法时,会用到 JVM Stack、PC Register、本地方法栈。
方法执行时的每行代码是有执行引擎中的解释器逐行执行,方法中的热点代码频繁调用的方法,由 JIT 编译器优化后执行,GC 会对堆中不用的对象进行回收。需要和操作系统打交道就需要使用到本地方法接口。
二.Java运行时数据区
Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。JDK 1.8 和之前的版本略有不同。
JDK 1.7:
JDK 1.8:
线程私有的:
- 程序计数器
- 虚拟机栈
- 本地方法栈
线程共享的:
- 堆
- 方法区
- 直接内存 (非运行时数据区的一部分)
2.1程序计数器(PC寄存器)
程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。保存着当前线程所正在执行的字节码指令的地址(行号)。在执行完当前指令后,程序计数器会自动加1或者跳转到其他指令的地址,以指向下一条要执行的指令的地址。分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。
为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
所以程序计数器主要有两个作用:
- 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
注意
- 程序计数器是唯一一个不会出现 OutOfMemoryError(OOM) 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡
- JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟。
有个地方我看了网上还是有争议的,有的说存的是下一条指令,但是我看了Oracle官方文档:
Chapter 2. The Structure of the Java Virtual Machine (oracle.com)
可以看到官网的意思是说,当时“currently being executed”也就是当前正在执行的指令。
分析一下具体的步骤:
- 程序计数器初始化:在程序开始运行之前,程序计数器会被初始化为指向第一条要执行的指令的地址。比如现在是10000;
- 指令执行:CPU从内存中取出程序计数器中存储的指令地址所对应的指令,并执行该指令。在执行完当前指令后,程序计数器会自动加1或者跳转到其他指令的地址,以指向下一条要执行的指令的地址。比如现在是10001;
- 指令跳转:当程序需要进行分支、循环或者函数调用等操作时,程序计数器会被更新为新的指令地址,以便CPU能够跳转到新的位置继续执行程序。这种跳转可以是条件性的或者无条件的,可以通过各种方式实现,例如条件语句、循环语句、函数调用等。
- 中断处理:当计算机遇到硬件故障、系统错误或者其他中断事件时,程序计数器也可能会被更新为中断服务例程的入口地址,以便CPU能够立即跳转到中断处理程序中执行相应的操作。
其实,为什么大家会有分歧我想从步骤中也能看出,在取和执行过程中还是当前指令的地址,在执行完成到一下次取之前存储的就是下一条指令的地址。所以这两种说法都说的通。
特点
- 是线程私有的
- 不会存在内存溢出
2.2Java虚拟机栈(Java Virtual Machine Stacks)
Java虚拟机栈描述的是Java方法执行的内存模型。Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法 调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stacks)的栈元素。
- 每个栈由多个栈帧(Frame)组成,对应着每次调用方法时所占用的内存,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
- 每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程
- 在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
- 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。
2.2.1栈帧的组成
操作指令助记符详解:
实战详解java反编译字节码(操作指令助记符)_字节码反编译_天然玩家的博客-CSDN博客
新建对象分为4步:
- new:新建对象,在堆中为对象分配存储空间,并压入操作数栈顶;
- dup:复制栈顶部一个字长内容,入栈(此时栈有两个相同地址);
- invkespecial:构造函数调用初始化方法,操作数栈顶弹出ByteCodeTest对象引用(dup);
- astore_1:从操作数栈顶取出ByteCodeTest对象存入局部变量1。
方法调用有5种方式:
- invokespecial:调用当前类方法;
- invokevirtual:调用引入类的方法;
- invokeinterface:调用接口方法;
- invokestatic:调用静态方法;
- invokedynamic:调用运行时解析的方法。
局部变量表
定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型。
局部变量表的容量以变量槽为最小单位,每个变量槽都可以存储32位长度的内存空间。例如boolean、byte、char、short、int、float、reference。对于64位长度的数据类型(long,double),虚拟机会以高位对齐方式为其分配两个连续的Slot空间,也就是相当于把一次long和double数据类型读写分割成为两次32位读写。
如果执行的是实例方法(没有被static修饰的方法),那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数,并且占用0位置这个变量槽。
其余参数则按照参数表顺序排列,占用从1开始的局部变量槽,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。
举例:
private static void test1() {
String a = "a";
}
private void test2() {
String a = "a";
}
test1是静态方法,查看字节码文件,astore_0表示将栈顶元素“a”放入局部变量表索引为0的位置
test2是实例方法,查看字节码文件,astore_1表示将栈顶元素“a”放入局部变量表索引为1的位置,那么0的位置就是隐含的this了
变量槽复用
为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变 量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用
private static void test1() {
{
String a = "a";
}
String b = "b";//此时变量a已经出了作用于,变量b会复用a的变量槽
}
代码中注释部分此时变量a已经出了作用域,变量b会复用a的变量槽
操作数栈
每一个独立的栈帧除了包含局部变量表以外,还包含一个后进先出 的 操作数栈,也可以称之为表达式栈。操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)和 出栈(pop)。操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
动态链接
主要服务一个方法需要调用其他方法的场景。Class 文件的常量池里保存有大量的符号引用比如方法引用的符号引用。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用,这个过程也被称为 动态连接 。
概念补充:符号引用、直接引用,参考以下文章
浅析 JVM 中的符号引用与直接引用_符号引用和直接引用_这瓜保熟么的博客-CSDN博客
2.2.2问题辨析
一个方法调用另一个方法,会创建很多栈帧吗?
会创建。如果一个栈中有动态链接调用别的方法,就会去创建新的栈帧,栈中是由顺序的,一个栈帧调用另一个栈帧,另一个栈帧就会排在调用者上面
垃圾回收是否涉及栈内存?
不会。栈内存是方法调用产生的,方法调用结束后会弹出栈。
栈内存分配越大越好吗?
不是。因为物理内存是一定的,栈内存越大,可以支持更多的递归调用,但是可执行的线程数就会越少。
方法内的局部变量是否线程安全?
不一定,如果方法中使用了外部传入的可变对象,或者方法返回了可变对象,那么就可能存在线程不安全的问题。因为可变对象的状态是可以被多个线程同时修改的,所以在多线程环境下使用这些可变对象可能会导致线程安全问题。
2.2.3逃逸分析
逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部 方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;
从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。 如果能证明一个对象不会逃逸到方法或线程之外(换句话说是别的方法或线程无法通过任何途径 访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实例采取不同程度的优化。
①栈上分配
栈上分配(Stack Allocations):在Java虚拟机中,Java堆上分配创建对象的内存空间几乎是 Java程序员都知道的常识,Java堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问到堆中存储的对象数据。虚拟机的垃圾收集子系统会回收堆中不再使用的对象,但回收动作无论是标记筛选出可回收对象,还是回收和整理内存,都需要耗费大量资源。
如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。
在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集子系统的压力将会下降很多。栈上分配可以支持方法逃逸,但不能支持线程逃逸。
代码示例
private void test2() {
String a = "a";
}
我们创建了对象“a”,但是该对象无论外部方法或者其他线程都是访问不到的,此时对象是分配在栈上随方法的退出而自动销毁。
②标量分析
标量替换(Scalar Replacement):若一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数就可以被称为标量。相对的,如果一个数据可以继续分解,那它就被称为聚合量,Java 中的对象就是典型的聚合量。
如果把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量 恢复为原始类型来访问,这个过程就称为标量替换。
假如逃逸分析能够证明一个对象不会被方法外部 访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上 (栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。
标量替换可以视作栈上分配的一种特例,实现更简单(不用考虑整个对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内
代码示例
public int test(int x) {
int xx = x + 2;
Point p = new Point(xx, 42);
return p.getX();
}
其中Point类的代码,就是一个包含x和y坐标的POJO类型
经过逃逸分析,发现在整个test()方法的范围内Point对象实例不会发生任何程度的逃逸, 这样可以对它进行标量替换优化,把其内部的x和y直接置换出来,分解为test()方法内的局部变量,从 而避免Point对象实例被实际创建,优化后的结果如下:
public int test(int x) {
int xx = x + 2;
int px = xx;
int py = 42 return px;
}
通过数据流分析,发现py的值其实对方法不会造成任何影响,那就可以放心地去做无效 代码消除得到最终优化结果
public int test(int x) { return x + 2; }
③同步消除
同步消除(Synchronization Elimination):线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争, 对这个变量实施的同步措施也就可以安全地消除掉
private void test2(){
System.out.println(new StringBuffer("a").append("b"));
}
这块代码append方法是加锁的,但是经过逃逸分析能够确定变量不会逃逸出线程,无法被其他线程访问,此时虽然append方法是加锁的,但是执行引擎执行时是不会加锁的。
关于逃逸分析的研究论文早在1999年就已经发表,但直到JDK 6,HotSpot才开始支持初步的逃逸 分析,而且到现在这项优化技术尚未足够成熟,仍有很大的改进余地。不成熟的原因主要是逃逸分析 的计算成本非常高,甚至不能保证逃逸分析带来的性能收益会高于它的消耗。如果要百分之百准确地 判断一个对象是否会逃逸,需要进行一系列复杂的数据流敏感的过程间分析,才能确定程序各个分支
执行时对此对象的影响。
可以试想一下,如果逃逸分析完毕后发现几乎找不到几个不逃逸的对象, 那这些运行期耗用的时间就白白浪费了,所以目前虚拟机只能采用不那么准确,但时间压力相对较小的算法来完成分析。
曾经在很长的一段时 间里,即使是服务端编译器,也默认不开启逃逸分析,甚至在某些版本(如JDK 6 Update 18)中还曾经完全禁止了这项优化,一直到JDK 7时这项优化才成为服务端编译器默认开启的选项。如果有需 要,或者确认对程序运行有益,用户也可以使用参数-XX:+DoEscapeAnalysis来手动开启逃逸分析, 开启之后可以通过参数-XX:+PrintEscapeAnalysis来查看分析结果。有了逃逸分析支持之后,用户可 以使用参数-XX:+EliminateAllocations来开启标量替换,使用+XX:+EliminateLocks来开启同步消 除,使用参数-XX:+PrintEliminateAllocations查看标量的替换情况。
2.2.4内存溢出
栈空间虽然不是无限的,但一般正常调用的情况下是不会出现问题的。不过,如果函数调用陷入无限循环的话,就会导致栈中被压入太多栈帧而占用太多空间,导致栈空间过深。那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
Java 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。
除了 StackOverFlowError 错误之外,栈还可能会出现OutOfMemoryError错误,这是因为如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
简单总结一下程序运行中栈可能会出现两种错误:
- StackOverFlowError: 若栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
- OutOfMemoryError: 如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
-Xss可以设置栈的内存大小
2.2.5线程运行诊断
例如某个java程序 cpu 占用过多
- top 命令,查看是哪个进程占用 CPU 过高
top
- 通过 ps 命令进一步查看是哪个线程占用 CPU 过高,eo后面跟自己感兴趣的查询内容,grep过滤,只看目标进程相关的
ps H -eo pid, tid(线程id), %cpu | grep 刚才通过 top 查到的进程号
- 通过 jstack 命令生成虚拟机指定进程当前时刻的线程快照,虚拟机堆栈追踪,线程快照就是当前虚拟机内指定进程的每一条线程正在执行方法堆栈的集合
jstack 进程id
2.3本地方法栈(Native Method Stacks)
和虚拟机栈所发挥的作用非常相似。区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
本地方法(Native)
由于java是一门高级语言,离硬件底层比较远,有时候无法操作底层的资源,于是,java添加了native关键字,被native关键字修饰的方法可以用其他语言重写,这样,我们就可以写一个本地方法,然后用C语言重写,这样来操作底层资源。当然,使用了native方法会导致系统的可移植性不高,这是需要注意的。
像Object类中的许多方法就是native,即本地方法
- 本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
- 方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种错误。
2.4堆
Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
Java 世界中“几乎”所有的对象都在堆中分配,但是,随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。
特点
- 堆是线程共享的。堆中对象都需要考虑线程安全的问题
- 有垃圾回收机制
2.4.1堆的演进
在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:
- 新生代内存(Young Generation)
- 老生代(Old Generation)
- 永久代(Permanent Generation)
下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最右面一层属于永久代。
(下图有点点误差,JDK7里堆应该是包括永久代的)
JDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空间) 取代,元空间使用的是本地内存。
大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 S0 或者 S1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
“Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的一半时,取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值”。
2.4.2堆的内容
主要有:
对象实例
- 类初始化生成的对象
- 基本数据类型的数组也是对象实例
字符串常量池
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
// 在堆中创建字符串对象”ab“
// 将字符串对象”ab“的引用保存在字符串常量池中
String aa = "ab";
// 直接返回字符串常量池中字符串对象”ab“的引用
String bb = "ab";
System.out.println(aa==bb);// true
HotSpot 虚拟机中字符串常量池的实现是 src/hotspot/share/classfile/stringTable.cpp ,StringTable 可以简单理解为一个固定大小的HashTable ,容量为 StringTableSize(可以通过 -XX:StringTableSize 参数来设置),保存的是字符串(key)和 字符串对象的引用(value)的映射关系,字符串对象的引用指向堆中的字符串对象。
JDK1.7 之前,字符串常量池存放在永久代。JDK1.7 字符串常量池和静态变量从永久代移动了 Java 堆中。
静态变量
静态变量是有static修饰的变量,jdk7时从方法区迁移至堆中
线程分配缓冲区(Thread Local Allocation Buffer)
- 线程私有,但是不影响java堆的共性
- 增加线程分配缓冲区是为了提升对象分配时的效率
2.4.3问题辨析
JDK 1.7 为什么要将字符串常量池和静态变量移动到堆中
主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。
2.4.4内存溢出
堆这里最容易出现的就是 OutOfMemoryError 错误,并且出现这种错误之后的表现形式还会有几种,比如:
- java.lang.OutOfMemoryError: GC Overhead Limit Exceeded:当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。
- java.lang.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx参数配置,若没有特别配置,将会使用默认值)
- ......
指定堆内存大小:
- 堆的最小值:-Xms 如-Xms2m
- 堆的最大值:-Xmx 如 -Xmx8m
2.4.5堆内存诊断
- jps 工具:查看当前系统中有哪些 java 进程
jps
- jmap 工具:查看Java进程的内存使用情况
jmap - heap 进程id
- jconsole 工具:图形界面的,多功能的监测工具,可以连续监测
jconsole
2.5方法区
《Java虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。”但对于HotSpotJVM而言,方法区还有一个别名叫做Non-Heap(非堆),目的就是要和堆分开。
《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,方法区到底要如何实现那就是虚拟机自己要考虑的事情了。也就是说,在不同的虚拟机实现上,方法区的实现是不同的。
2.5.1方法区和永久代以及元空间的关系
方法区和永久代以及元空间的关系很像 Java 中接口和类的关系,类实现了接口,这里的类就可以看作是永久代和元空间,接口可以看作是方法区,也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。并且,永久代是 JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现变成了元空间。
2.5.2为什么要将 永久代 替换为 元空间
《深入理解 Java 虚拟机》第 3 版 2.2.5:
①整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整,而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
当元空间溢出时会得到如下错误:java.lang.OutOfMemoryError: MetaSpace
你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。
②元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize 控制了, 而由系统的实际可用空间来控制,这样能加载的类就更多了。
③在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了。
2.5.3方法区的内容
方法区用于存储已被虚拟机加载的Class类型信息、常量、静态变量、即时编译器编译后的代码缓存等。而堆中主要存放的是实例化的对象。
方法区是所有线程共享的内存,在java8以前是放在JVM内存中的,由永久代实现,受JVM内存大小参数的限制,在java8中移除了永久代的内容,方法区由元空间(Meta Space)实现,并直接放到了本地内存中,不受JVM参数的限制(当然,如果物理内存被占满了,方法区也会报OOM),并且将原来放在方法区的字符串常量池和静态变量都转移到了Java堆中,方法区与其他区域不同的地方在于,方法区在编译期间和类加载完成后的内容有少许不同,不过总的来说分为这两部分:
①类元信息(Klass)
类元信息在类编译期间放入方法区,里面放置了类的基本信息,包括类的版本、字段、方法、接口以及常量池表(Constant Pool Table)
常量池表(Constant Pool Table)
编译如下代码:
public class Test {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}
然后使用 javap -v Test.class 命令反编译查看结果。
每条指令都会对应常量池表中一个地址,常量池表中的地址可能对应着一个类名、方法名、参数类型等信息。
俗称静态常量池,又称常量池表,存在于*.class文件中,就是一张表,存储了类在编译期间生成的字面量、符号引用,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量信息,这些信息在类加载完后会被解析到运行时常量池中。
概念补充:字面量
java代码在编译过程中是无法构建引用的,字面量就是在编译时对于数据的一种表示:
int a=1;//这个1便是字面量
String b="iloveu";//iloveu便是字面量
②运行时常量池(Runtime Constant Pool)
- 常量池是 *.class 文件中的,当该类被加载以后,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址。
- 运行时常量池具备动态性,可以添加数据,比较多的使用就是String类的intern()方法。
运行时常量池、方法区、字符串常量池这些都是不随虚拟机实现而改变的逻辑概念,是公共且抽象的,Metaspace、Heap 是与具体某种虚拟机实现相关的物理概念,是私有且具体的
2.5.4问题辨析
成员变量、局部变量、类变量分别存储在内存的什么地方?
类变量
- 类变量是用static修饰符修饰,定义在方法外的变量,随着java进程产生和销毁
- 在java7之前把静态变量存放于方法区,在java7时存放在堆中
成员变量
- 成员变量是定义在类中,但是没有static修饰符修饰的变量,随着类的实例产生和销毁,是类实例的一部分
- 由于是实例的一部分,在类初始化的时候,从运行时常量池取出直接引用或者值,与初始化的对象一起放入堆中
局部变量
- 局部变量是定义在类的方法中的变量
- 在所在方法被调用时放入虚拟机栈的栈帧中,方法执行结束后从虚拟机栈中弹出,所以存放在虚拟机栈中
由final修饰的常量存放在哪里?
final关键字并不影响在内存中的位置,具体位置请参考上一问题。
类常量池、运行时常量池、字符串常量池有什么关系?有什么区别?
- 类常量池与运行时常量池都存储在方法区,而字符串常量池在jdk7时就已经从方法区迁移到了java堆中。
- 在类编译过程中,会把类元信息放到方法区,类元信息的其中一部分便是类常量池,主要存放字面量和符号引用,而字面量的一部分便是文本字符,在类加载时将字面量和符号引用解析为直接引用存储在运行时常量池;
- 对于文本字符来说,它们会在解析时查找字符串常量池,查出这个文本字符对应的字符串对象的直接引用,将直接引用存储在运行时常量池;字符串常量池存储的是字符串对象的引用,而不是字符串本身。
2.5.5方法区的特点
- 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。
- 方法区是线程共享的,多个线程都用到一个类的时候,若这个类还未被加载,应该只有一个线程去加载类,其他线程等待;
- 方法区有垃圾回收机制,一些类不再被使用则变为垃圾,需要进行垃圾清理。
- 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。
- 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
- 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutOfMemoryError: PermGen space 或者java.lang.OutOfMemoryError: Metaspace。比如下面三种情况:
- 加载大量的第三方的jar包;
- Tomcat部署的工程过多(30~50个);
- 大量动态的生成反射类;
2.5.6设置方法区的大小
方法区的大小不必是固定的,JVM可以根据应用的需要动态调整。
jdk7及以前:
- -XX:PermSize=N //方法区 (永久代) 初始大小
- -XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen
当JVM加载的类信息容量超过了这个值,会报异常OutofMemoryError:PermGen space
JDK8以后:
使用元空间代替了永久代,元空间大小也是可以动态调整的
- -XX:MetaspaceSize=N //设置 Metaspace 的初始(和最小大小)
- -XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小
因为元空间使用的是本地内存,所以默认值大小依赖于各个平台。
在windows下,-XX:MetaspaceSize是21M,-XX:MaxMetaspaceSize的值是-1,即没有限制,理论上就是系统的有可用内存大小。
与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元空间发生溢出,虚拟机一样会抛出异常OutOfMemoryError:Metaspace。
2.6再谈字符串常量池(StringTable)
2.6.1字面量创建字符串
public static void main(String[] args){
String s1 = "a";
String s2 = "b";
String s3 = "ab";
}
在上面这段代码中,通过字符串字面量的方式新建了几个String。对于变量s1,s2,s3,我们都知道它们被存在了栈中。可是后面的字符串呢?它被存储在哪个地方呢?经过反编译,我们得到如下jvm指令。
这里的#2就是“a”,当类加载的时候,常量池中的信息会加载到运行时常量池中,此时的a,b,ab都还是符号,没有变成java对象。当运行此方法,执行到对应的代码时,才会将符号a变成“a”字符串对象,并将对象放入StringTable中。 需要注意的是,普通的java对象在类加载的时候就会生成并放入堆中,而这种方式生成的String不同,只有当执行到新建String的代码时才会生成字符串对象。
- StringTable是一个哈希表,长度固定,“a”就是哈希表的key。一开始的时候,会根据“a”到串池中找其对象,一开始是没有的,所以就会创建一个并放入串池中。串池为 [“a”]。
- 执行到指令ldc #3时,会和上面一样,生成一个“b”对象并放入串池中,串池变为[“a”, “b”]。
- 同样地,后面会生成“ab”对象并放入串池中。串池变为[“a”, “b”, “ab”]。
2.6.2字符串变量拼接
观察下面的代码,请问输出结果是什么?
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3 == s4); // false
一样,先拿到反编译的jvm指令。
- 前面的指令我们已经很熟悉,观察行号为9的指令,这里是个new。这就说明s4的创建方式和s1、s2、s3不同,它是在堆里新建了一个对象,前面根据字面量创建的则是在串池中生成了字符串对象。
- 观察行号9的指令后面的注释,可以知道这里是new了一个StringBuilder对象。接着看17,21,可以发现“s1 + s2”的方式是通过StringBuilder对象调用append方法实现的。
- 最后看24,最后是调用了toString方法生成了新的字符串对象。
// StringBuilder中的toString方法
public String toString(){
// 即根据拼接好的值,创建一个新的字符串对象
return new String(value, 0, count);
}
以上分析就想要说明:即当两个字符串变量拼接时,jvm会创建一个StringBuilder对象,利用其append方法实现变量的拼接。最后再通过其toString方法生成一个新的String对象。
最后我们看输出结果,发现s3不等于s4,这说明s3指向串池中的“ab”对象,s4指向堆中的“ab”对象。这是两个不同的对象。
2.6.3字符串常量拼接
观察下面的代码,请问输出结果是什么?
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
String s5 = "a" + "b";
System.out.println(s3 == s5); // true
直接反编译:
可以看到s5的创建和s3一样。这其实是编译期的优化。编译期间,编译器发现这是两个常量相加,结果是确定的,所以就直接让s5等于“ab”。
2.6.4 intern方法
调用字符串对象的 intern 方法,会将该字符串对象尝试放入到串池中
JDK7及以后:
- 如果串池中没有该字符串对象或引用,则将堆中对象的引用放入串池
- 如果有该字符串对象,则放入失败,无论放入是否成功,都会返回串池中的字符串对象引用
// 堆 new String("a") new String("b") new String("ab") ["a", "b"]
String s = new String("a") + new String("b");//此时只是堆中有个“ab”的对象,串池中无
String s2 = s.intern(); //将堆中对象的引用放入串池
String x = "ab";
System.out.println( s2 == x);//true
System.out.println( s == x );//true
JDK6:
- 如果串池中没有该字符串对象或引用,会将堆中的字符串对象复制一份放到串池中,最后返回StringTable中刚加入的对象。
- 如果有该字符串对象,则放入失败,无论放入是否成功,都会返回串池中的字符串对象引用