运行时数据区

分类

  1. 线程不共享: 程序计数器、Java虚拟机栈、本地方法栈
  2. 线程共享: 方法区、堆区

程序计数器

Program Counter Register, 也叫做PC寄存器, 每个线程会通过程序计数器记录当前要执行的字节码指令的地址

作用

  1. 控制程序指令的执行, 比如跳转, 分支, 异常
  2. 多线程情况下JVM通过程序计数器记录CPU切换前执行到哪一句, 切换回来后执行并继续解释运行

程序计数器会产生内存溢出的问题吗

  • 内存溢出指在使用某一块内存区域时, 存放的数据需要占用的内存大小超过了虚拟机能够提供的内存上限
  • 每个线程只存储一个固定长度的内存地址, 因此程序计数器不会产生内存溢出
  • PC不用程序员修改

Java虚拟机栈

先进后出(FILO), 每个方法调用一个栈帧来保存

  • Java虚拟机栈随着线程的创建而创建, 线程销毁则栈回收
  • 由于方法可能会在不同的线程中执行, 所以每个线程都有自己的虚拟机栈

栈帧组成

局部变量表, 操作数栈, 帧数据

  1. 局部变量表: 方法执行过程中存放所有的局部变量, 与字节码局部变量表不太一样
    • 栈帧中的局部变量表是一个数组, 每一个位置成为槽slot, long, double占用两个槽, 其他类型占用一个槽
    • 实例方法中的序号为0的位置存放的是this, 指的是当前调用方法的对象, 运行时会在内存中存放实例对象的地址
    • 方法参数也会保存在局部变量表中, 顺序与参数定义顺序一致
    • 所以局部变量表保存了实例方法的this对象, 方法的参数, 方法体中声明的局部变量
1
2
3
4
5
6
7
8
9
10
11
12
13
public void test(int k, int m) {
{
int a = 1;
int b = 2;
}
{
int c = 1;
}
int i = 0;
long j = 1;
}
// 占用了6个槽, 为了节省空间, 槽是可以复用的, 一旦某个局部变量不再生效, 则当前的槽可以再次使用
// ab在代码块中, 代码块结束以后, ab就释放了
  1. 操作数栈: 存放中间数据的一块区域, 如果一条指令将一个值压入操作数栈, 则后面的指令可以弹出并使用该值

编译期就可以确定操作数栈的最大深度, 从而在执行时正确分配内存大小

  1. 帧数据: 包含动态链接, 方法出口, 异常表的引用
    • 动态链接保存的是符号引用到运行时常量池中内存地址的映射关系(比如引用其他类)
    • 方法出口: 方法在正确或异常结束时, 栈帧会被弹出, PC应该指向上一个栈帧中的下一条指令的地址, 所以在当前栈帧中, 需要存储此方法的出口的地址
    • 异常表引用: 存放代码中的异常处理信息, 包含异常捕获的生效范围以及异常发生以后跳转到的字节码指令位置
  1. 栈内存溢出

    • 如果栈帧过多, 占用内存超过栈内存可以分配的最大空间以后就会出现内存溢出, 报StackOverflowError错误
    • 比如递归的时候没有设置递归出口, 则会栈内存溢出
  2. 设置栈内存

    • 通过参数-Xss<值>设置虚拟机栈大小, 单位是[字节, 且必须是1024的倍数, 默认] [k或者K] [m或者M] [g或者G]
    • 也可以用-XX:ThreadStackSize=<大小>, 但是比较复杂
    • HotSpot JVM对栈的大小的最大值和最小值有要求, 超过范围会自动调整
    • 局部变量过多, 操作数栈深度过大也会影响栈内存大小
    • 可以手动指定-Xss256k节省内存

本地方法栈

Java虚拟机栈存储了Java方法调用时的栈帧, 本地方法栈存储的是本地方法的栈帧, 用cpp编写的

  • Hotspot虚拟机中, Java虚拟机栈和本地方法栈实现上用了同一个栈空间, 本地方法栈会在栈内存上生成一个栈帧, 临时保存方法参数的同时, 方便出现异常时也把本地方法的栈信息打印出来

堆内存是最大的内存区域, 创建出来的对象都在堆上

栈上的局部变量表中可以存放堆上对象的引用, 静态变量也可以存放堆对象的引用, 通过静态变量可以实现对象在线程之间共享

  1. 堆内存溢出
    • 堆内存大小有上限, 如果一直向堆中放入对象达到上限后, 就会抛出OutOfMemory错误
    • 有三个需要关注的值: used, total, max, 在Arthas中可以使用dashbord -i or memory命令查看
      • used: 当前已经使用的堆内存
      • total: JVM已经分配的可用堆内存
      • max: 是可以分配的最大堆内存
        • total内存不足的时候, JVM会扩大total, 直到达到max为止
        • 不是total = used = max的时候产生堆内存溢出, 这是因为有垃圾回收器存在的原因
        • 默认情况下, max默认是系统内存的1/4, total默认是系统内存的1/64, 实际应用中需要单独设置
        • -Xmx值 -Xms值, 分别表示maxtotal的大小, max必须大于2MB, total必须大于1MB
        • 但是实际上Arthas堆内存显示的比实际上分配的要小一点, 这是因为使用了JMX技术, 与垃圾回收器有关, 计算的是可以分配对象的内存, 而不是整个内存
    • 一般设置的时候, 可以将total设置成max相同的大小, 减少申请内存和压缩内存的开销

方法区

存放基础信息的位置(类的元信息, 运行时常量池, 字符串常量池), 线程间共享, 是一个虚拟概念

  • 用来存放每个类的基本信息(元信息), 一般称为InstanceKlass对象, 在类的加载阶段完成
  • 还用来存放了字节码中的运行时常量池, 通过编号查表的方式找到常量, 称为静态常量池
  • 常量池加载到内存中之后, 可以通过内地址快读定位到常量池中的内容, 就是运行时常量池

JDK 7以及之前

  • 方法区存放在堆的永久代空间, 堆的大小由JVM参数控制
  • Arthas中可以看见ps_perm_gen就是永久代, 因为在堆上, 所以设置了max, 存储空间大小有限制

JDK 8以及之后

  • 方法区存放在元空间中, 位于操作系统内存中, 独立于JVM内存之外, 默认情况下只要不超过操作系统上限, 可以一直分配
  • Arthas中可以看见metaspace, 就是元空间, max=-1, 表示没有上限, 因此只要不超过操作系统的上限即可
  1. 方法区溢出
    • JDK 7在堆上, 十几万个类会出现错误
    • JDK 8运行上百万次, 不会出现错误
    • JDK 7方法区存储在堆区中的永久代空间, 可以设置-XX:MaxPermSize=值来控制
    • JDK 8方法区位于元空间中, 默认不超过操作系统内存上限即可, 同样可以使用-XX:MaxMetaspaceSize=值设置元空间大小
    • 启动程序的时候最好设置元空间的最大大小, 没有特殊情况设置成256M, 以免占用其他程序的内存, 能够容纳三十多万个类的加载

字符串常量池

存储代码中定义的常量字符串内容, 比如"123"

1
2
3
4
5
6
7
8
9
10
11
public class Test {
public static void main(String[] args) {
String s1 = new String("abc");
String s2 = "abc";
System.out.println(s1 == s2);
}
}
// 编译成字节码以后, "abc"会加入到静态常量池中
// s1通过new创建, 所以"abc"放在堆内存中, 由s1保存
// s2没有使用new, 所以s2存放的是字符串常量池中的"abc"
// 所以最后打印s1 == s2两个地址不同, 返回false
  • 早期的设计中, 字符串常量池是运行时常量池的一部分, 存储位置相同, 但后续将字符串常量池和运行时常量池进行拆分
  • JDK 7之前, 运行时常量池包含字符串常量池, 都在方法区的永久代中
  • JDK 7时, 字符串常量池从方法区拿到了堆中, 运行时常量池剩下的东西还在方法区的永久代中
  • JDK 8之后, 没有永久代了, 所以运行时常量池在元空间中, 而字符串常量池仍然在堆中
1
2
3
4
5
6
7
8
9
10
11
12
public static void main(String[] args) {
String a = "1";
String b = "2";
String c = "12";
String d = a + b;
System.out.println(c == d);
}
// 编译成字节码以后, 字符串常量池中会有"1", "2", "12"
// c 指向的是字符串常量池中的"12"
// d 中的 + 变成了使用StringBuilder方法进行连接
// d指向的是堆内存中的"12"
// 所以是false
1
2
3
4
5
6
7
8
9
public static void main(String[] args) {
String a = "1";
String b = "2";
String c = "12";
String d = "1" + "2";
System.out.println(c == d);
}
// 此时cd都是在字符串常量池中
// 所以返回true
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void main(String[] args) {
String s1 = new StringBuilder().append("think").append("123").toString();
System.out.println(s1.intern() == s1);
String s2 = new StringBuilder().append("ja").append("va").toString();
System.out.println(s2.intern() == s2);
}
// String.intern()是手动将字符串放在字符串常量池中
// 比如多次输入, 每次Scanner.next().intern(), 如果有两次输入相同的字符串, 就可以减少存储的消耗, 只需要往字符串常量池中存储一份字符串即可
// JDK 6这个版本中, intern()会把第一次遇到的字符串实例复制到永久代字符串常量池中, 返回的也是永久代字符串实例的引用, JVM启动时会把"java"加入到常量池中
// 所以JDK 6中, s1.intern()方法在字符串常量池中, s1在堆上,返回false
// s2.intern()在字符串常量池中, s2在堆上, 所以返回false
// JDK 7之后的版本, 由于字符串常量池在堆上, 所以intern()会把第一次遇到的字符串引用放在字符串常量池中
// s1.intern()返回的就是s1在堆上的引用, 也就是地址, 所以返回true
// s2.intern()由于java已经在字符串常量池中有了,. 所以s2.intern()是字符串常量池中的地址, s2是堆中的地址, 因此返回false
  1. 静态变量存储在哪里?
    • JDK 6以及之前, 静态变量存储在方法区中, 也就是永久代中
    • JDK 7以及之后的版本中, 静态变量存储在Class对象中, 脱离了永久代

直接内存

不属于Java运行时的内存区域, 在JDK 1.4中引入了NIO机制, 使用了直接内存, 比如Netty网络框架

  • 直接内存解决两个问题

    1. Java堆中的对象如果不再使用要回收, 回收时会影响对象的创建和使用
    2. IO操作比如读文件, 需要先把文件读入直接内存(缓冲区), 然后再把数据复制到Java堆中
  • 现在直接放入直接内存即可, 同时Java堆上维护直接内存的引用, 减少了数据复制的开销, 写文件也是同样的思路

  • 所以现在不需要从直接内存上复制到堆中了, 减少回收对象的影响, 提升读写文件的效率

  • 可以使用ByteBuffer在直接内存上创建数据, 在Arthas中的memory可以查看direct部分的相关信息

    • ByteBuffer directBuffer = ByteBuffer.allocateDirect(size);
  1. 直接内存存在溢出现象
    • -XX:MaxDirectMemorySize=值修改直接内存的大小, 如果不设置这个参数, 则会自动选择最大分配的大小
    • 如果底层使用了NIO, 则需要设置这个参数; 如果没有用到直接内存, 也可以不设置这个参数
    • 具体设置的大小需要进行压力测试以后, 确定最大内存

总结

  1. 运行时数据区分为了哪几个部分? 每个部分的作用是什么?
    • 程序计数器, Java虚拟机栈, 本地方法栈. 是线程不共享, 每个线程有一块独立的区域
      • 程序计数器: 记录当前要执行的字节码指令的地址, 不会出现内存溢出的问题
      • Java虚拟机栈和本地方法栈: 每个方法的调用会使用一个栈帧来保存数据, 会内存溢出, 一般因为递归没有出口
    • 方法区, 堆, 是线程共享的
      • 堆中存放创建的对象, 最容易内存溢出, 与垃圾回收有关
      • 方法区存放类的元信息, 以及常量池, 会出现内存溢出
  2. 不同JDK版本之间运行时区域的区别是什么?
    • JDK 6方法区放在堆里面, 称为永久代, 字符串常量池放在方法区中
    • JDK 7字符串常量池从方法区中独立, 放在了堆上
    • JDK 8字符串常量池依然在堆里面, 但是方法区称为元空间, 从堆中独立