程序计数器(Program Counter Register)
程序计数器里面存放的是下一条 JVM 指令代码的执行地址,概念上有点类似数据库结果集遍历时的游标,不断指向下一条,物理上是通过寄存器实现的,寄存区在 CPU 上是非常快的单元
在 JVM 的规范中,每条线程都有自己的程序计数器,所以程序计数器是线程私有的,因为当发生线程切换的时候,需要知道切换回来后下一步应该执行什么操作,如果不是线程私有则会造成混乱
程序计数器是 JVM 中唯一不会存在 OOM 的区域,这一点是 JVM 规范上要求的
虚拟机栈
说虚拟机栈之前先说栈,栈的特点就是先进后出或后进先出
虚拟机栈是线程运行时需要的内存空间,线程运行的目的是为了执行代码,代码由一个个方法组成,当线程运行的时候,每个方法需要的内存空间就是栈帧(参数,局部变量,返回地址),每个栈由多个栈帧组成,对应着每次方法调用时所占用的内存,调用方法时入栈,执行完出栈,每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法(栈顶部),栈是有一定的深度的,当发生递归死循环时,一直有方法入栈但不出栈,导致栈溢出,抛出 java.lang.StackOverflowError
IDEA debug 时左下角的 Frame 就是栈帧,比较直观,从上到下,右边还可以看到栈内的局部变量值
垃圾回收是否涉及栈内存
不会
栈内存随着出栈就释放掉,垃圾回收主要用来回收堆内存
栈内存分配越大越好吗
不是
-Xss size(比如-Xss1m,没有等号)
Windows 下取决于虚拟内存的设置,其他主流操作系统上默认为 1024KB(1MB)
物理内存的大小是固定的,栈内存分配设置得越大,占用越多内存,由于栈是线程私有的,所以线程占用的内存就会变大,那么操作系统可以创建的总线程数就会变小
比如操作系统 10M 内存(只是举例),本来一个线程默认占用 1M 内存,那么可以创建 10 个线程,但设置-Xss2m 后,只能创建 5 个线程了
-Xss 设置得大,只会增加方法递归调用的次数,一般不会提升性能,就是不容易爆栈,但死循环递归该爆还得爆
方法内的局部变量是否线程安全
是
每个线程都有私有的栈帧,里面会有各自独立的变量,互不干扰。线程安全问题出现在共享变量上,如果方法内局部变量没有逃离方法的作用范围,那么这个局部变量就是线程安全的
线上诊断方法
通常都是十八般武艺配合着来用,包括但不限于:
- top 命令,查看 %CPU,配合 grep java
- ps H -eo pid,tid,%cpu(小写 cpu) | grep 进程号
- jstack 进程 id,会列出所有线程,有线程号 nid(16 进制),printf ‘%x’ 10 进制 id
- jstack 查看是否出现死锁
本地方法栈
native 地方法运行时所需要的空间,其他的每什么好说的了,不懂
堆
通过 new 关键字创建的对象都会使用堆内存
堆里面的对象是线程共享的,所以一般都需要考虑线程安全的问题,堆有垃圾回收机制,并且会发生内存溢出 java.lang.OutOfMemoryError: Java heap sapce
JVM 参数常用的有两个,用于设置内存占用大小
-Xms:堆的初始大小
-Xmx:堆的最大占用
为什么一般都会将这两个参数设置成一样?
因为当堆内存不足需要进行扩容的时候,会发生内存抖动,对程序运行的稳定性会有一定的影响
线上诊断方法
包括但不限于:
- jps:查看系统中存在的 Java 进程
- jmap:查看堆内存占用情况,jmap -head pid
- jconsole:图形界面的,多功能监测工具,可以连续监测(可测出是否发生死锁)
- jvisualvm:堆 dump
方法区
线程共享,存放一些类的基本信息(类,类加载器,运行时常量池),逻辑上是堆的一部分,不同厂商有不同的实现
HotSpot 在 1.7 之前用永久代(堆),1.8 后用元空间(操作系统内存)
方法区是规范,永久代或者元空间是其中一种实现,方法区会发生内存溢出
1.8 以前会导致永久代内存溢出:-XX:MaxPermSize=m(OutOfMemory:PermGen space)
1.8 之后会导致元空间内存溢出:-XX:MaxMetaspaceSize=m(OutOfMemory:Metaspace)
字符串常量池,串池(StringTable)
常量池:是一张表,维护类名,方法名,参数类型,字面量等信息
运行时常量池:当类被加载时,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址
字符串常量池:字符串是日常开发中使用得非常多的类,池化技术某种程度上会有性能上的提升
如何判断字符串是在堆还是在串池中
1 | public static void main(String[] args) { |
javap -v x.class 查看二进制字节码(类基本信息,常量池,类方法定义,包含了虚拟机指令)
不同的 JDK 版本可以看到反编译出来的不一样
JDK 1.8
1 | Classfile /home/linweiyuan/Temp/test/Test.class |
JDK 9+,我这里用的是 JDK 17,但其实从 9 开始就变了,之前有试过,最近整理笔记,电脑上 JDK 9 已经没有了
1 | Classfile /home/linweiyuan/Temp/test/Test.class |
得出结论
1 | // JDK 1.8 |
StringTable 特性
- 常量池中的字符串只是符号,第一次用到时才变为对象
- 利用串池的机制,可以避免重复创建字符串对象
- 字符串常量拼接的原理是编译期优化
- 用 intern() 可以主动把串池中还没有的字符串对象放入串池(尝试放入串池,有则不会放入,没有则放入,无论放入是否成功,返回的都是串池中的对象)
这里有一个要注意的地方,JDK 1.8 intern() 将自己放入串池(一个对象),JDK 1.6 把自己复制一份放进串池(两个对象)
1 | String s = new String("a") + new String("b"); // 此时 s 在堆中 |
1 | String x = "ab"; // 串池中已经有了 ab |
1 | String s = new String("a") + new String("b"); // 此时 s 在堆中 |
StringTable 位置
- 1.6:常量池的一部分,位于永久代中(永久代 Full GC 才会触发,字符串用的场景很多,但是好多都是可以回收的,内存占用太多会造成永久代内存不足)(PermGen space)
- 1.8:堆中的一部分(堆 Minor GC 就会触发)(Heap space)
- -XX:-UseFCOverheadLimit(+启用,-禁用,默认启用):如果 98%的时间花在了垃圾回收上,但是只有 2% 的堆内存回收了,此时 JVM 直接抛出
OutOfMemoryError:GC overhead limit exceeded
StringTable 性能调优
StringTable 本质上是一个哈希表,哈希表性能与桶的个数有关
- 桶个数太大,元素分散,哈希碰撞机率减少,检索速度较快
- 桶个数太小,哈希碰撞机率增加,链表就会变长,查找速度就会降低
可以通过 JVM 参数调整桶的个数,-XX:StringTableSize=12345(最小值 1009)
垃圾回收
最快的 GC 是不发生 GC
如何判断对象是否能回收
- 引用计数法(有循环引用问题,A 引用 B,B 引用 A,都不会被回收,JVM 不是这种)
- 可达性分析算法(JVM 采用这种,GC Roots)
- 哪种可作为 Roots?(可以用 Eclipse Memory Analyzer 查看,jps -> jmap -dump:format=b,live,file=m.bin pid)
- System Class(核心的类,Object, HashMap, String 等)
- Native Stack(操作系统方法执行时引用的 Java 对象)
- Thread(活动线程)
- Busy Monitor(被加锁的对象,回收了,就无法解锁)
- 哪种可作为 Roots?(可以用 Eclipse Memory Analyzer 查看,jps -> jmap -dump:format=b,live,file=m.bin pid)
- 四种引用
- 强引用(new,赋值,沿着 GC Roots 能找到,就不会被回收)
- 软引用(没有强引用时,发生垃圾回收,如果内存不足,再执行一次,被回收,可配合引用队列使用释放自身)
- SoftReference
- ReferenceQueue, new SoftReference(obj, queue)
- 当 obj 被回收时,进入队列
- queue.poll() 获取自身,遍历 remove()
- 弱引用(没有强引用时,只要发生了垃圾回收,被回收,可配合引用队列使用释放自身)
- WeakReference
- 虚引用(必须配合引用队列使用,用于 NIO 中,由 Reference Handler 处理)
- PhantomReference
- 终结器引用(必须配合引用队列使用,finalize() 后进入队列,由 Finalizer 处理)
垃圾回收算法
- 标记清除:速度较快,容易产生内存碎片(空间不连续,数组放不下)
- 标记整理:速度较慢,没有内存碎片,标记 -> 清除 -> 整理(会移动对象,因此对象的引用地址会变,需要做额外的工作来更新)
- 复制:没有内存碎片,需要占用双倍内存空间,将内存分成大小相等的两块(from, to)发生垃圾回收时,交换位置
分代垃圾回收
新生代(朝生夕死):
- eden
- from
- to
垃圾回收过程(GC 都会 Stop The World,时间上新生代短一点,老年代长一点,暂停其他用户线程,让垃圾回收线程先执行完)
- 新对象默认存放在 eden
- 当空间不足时,触发 Minor GC
- 标记-清除-复制,把存活的复制到 to(注意不是到 from),寿命加 1
- 交换 from to 位置(谁是空的谁就是 to)
- 继续放对象
- …
- eden 存活的复制到 to,寿命加 1
- 原来在 from 的移到存活后移到 to,寿命加 1
- 交换 from to 位置
- …
- 寿命超过阈值(15,保存在对象头,4bit,最高 1111 -> 15),晋升到老年代
- 如果老年代也放不下,先尝试 Minor GC, 内存还不够,触发 Full GC,再不够,抛出 OOM
- 大对象在新生代空间不够,但老年代空间够的情况下,直接晋升
相关 JVM 参数
参数 | 含义 |
---|---|
-Xms | 初始堆大小 |
-Xmx / -XX:MaxHeapSize=size | 堆最大大小 |
-Xmn / -XX:NewSize=size, -XX:MaxNewSize=size | 新生代大小 |
-XX:InitialSurvivorRatio=ratio, -XX:+UseAdaptiveSizePolicy | 幸存区比例(动态) |
-XX:SurvivorRatio=ratio | 幸存区比例 |
-XX:MaxTenuringThreshold=threshold | 晋升阈值 |
-XX:+PrintTenuringDistribution | 晋升详情 |
-XX:+PrintGCDetails -verbose:gc | GC 详情 |
-XX:+ScavengeBeforeFullGC | Full GC 前 Minor GC |
线程内的 OOM 不会导致整个 JVM 挂掉
垃圾回收器
- 串行
- 单线程
- 适合堆内存较小,个人电脑,CPU 个数少(因为是单线程)
- -XX:+UseSerialGC=Serial+SerialOld
- 吞吐量优先(1.8 默认)
- 多线程
- 适合堆内存较大,多核 CPU
- 让单位时间内,STW 的时间最短
- -XX:+UseParallelGC
- -XX:+UseParallelOldGC
- -XX:+UseAdaptiveSizePolicy
- -XX:GCTimeRatio=99 (1 / 1 + ratio, 默认 99,0.01,100 分钟内只能一分钟用于垃圾回收,因为上一行配置了动态调整,所以一般会自动增加堆的大小,大小增加了,就能放更多的东西,垃圾回收的次数就会降低,吞吐量就会提高,一般设置 19)
- -XX:MaxGCPauseMillis=200(毫秒,和上面的配置冲突,只能取折中,因为堆增大了,虽然 GC 次数减少,但单次的时间会增加)
- -XX:ParallelGCThreads=n
- 响应时间优先
- 多线程
- 适合堆内存较大,多核 CPU
- 尽可能让单次 STW 的时间最短
- -XX:+UseConcMarkSweepGC
- -XX:+UseParNewGC SerialOld
- -XX:ParallelGCThreads=n
- -XX:ConcGCThreads=n
- -XX:CMSInitiatingOccupancyFraction=percent
- -XX:+CMSScavengeBeforeRemark
如何区分吞吐量优先和响应时间优先?
吞吐量优先:比如,每次花费 0.2 秒,一小时两次,共 0.4 秒,垃圾回收时间占比越低,吞吐量越高
响应时间优先:比如,每次只需 0.1 秒,但可能一小时发生了 5 次,共 0.5 秒
- G1(Garbage First,JDK 9 默认,废弃 CMS)
- 同时注重吞吐量和低延迟,默认的暂停目标是 200ms
- 超大堆内存,将堆划分成多个大小相等的 Region
- 整体上是标记-整理,两个区域之间是复制
- -XX:+UseG1GZ
- -XX:G1HeapRegionSize=size
- -XX:MaxGCPauseMillis=ms