深入理解JVM

99 阅读35分钟

JVM

Java Virtual Machine | Java程序的运行环境(Java二进制字节码的运行环境)

优点:

  1. 一次编写,到处运行

    JVM的主要目标之一 —— 跨平台能力

    本质上,JVM对不同的操作系统写了不同的代码,而且据说有用多种语言的多种实现。是一种在操作系统之上的中间层——JVM为上层屏蔽了不同操作系统的差异(系统调用,内存管理,线程调度...)

  2. 自动内存管理,垃圾回收机制

  3. else

    1. 数组下标越界检查:防止数据覆盖
    2. 多态(面向对象基石),JVM用...实现?

常见的JVM

JVM本身是一种规范,满足规范的实现都是JVM

image20230822093117018

各种实现的底层方法不同,常用JVM是HotSpot

JVM大致流程

image20230822093730510

  1. Java文件被编译为字节码文件
  2. 进入类加载器加载到内存中
  3. 方法区存放每个类
  4. 堆中存放每个对象
  5. 通过执行引擎完成对对象的调用 | 通过GC垃圾回收机制处理没用的对象
  6. 程序执行过程中需要使用 虚拟机栈、程序计数器、本地方法栈等。
  • java代码执行的大致流程:

    Java源代码编译成.class二进制字节码文件(本身人类不可读,但可以通过反编译 成类,或者反编译成通过助记符表示的JVM指令)

    通过解释器变成机器码

    将机器码交给CPU执行

JVM内存结构

  1. 程序计数器

    image20230822094459748

    作用:

    • 记住下一条JVM指令的执行地址

      对于JVM指令而言,每条指令匹配一个执行地址

      原因:

      1. 想必是因为Java中代码执行顺序不同于面向过程,是通过对象交织起来的,如此一来执行顺序的逻辑需要额外管理

        like: 调用方法后返回到哪里...似乎Java中一切跳跃的代码都是在调用方法

---

by the way :

对比前端代码执行和Java中swing库的执行逻辑

- 前端:由于构建DOM树的通过每个组件绑定的方法是确定的,所以可以通过事件指定触发script标签中定义的某个具体方法。

  --- 也就是代码的执行顺序并非严格按照script从上到下

- Swing:由于给每个组件绑定的不是方法,而是监听器对象,于是对一个事件会引发哪些方法而言,只能遍历监听器的触发方法

  --- 代码执行顺序还是从上到下,不过是由方法调用插入了别的代码


---

2. CPU切换后需要保存某一线程的进度

}

解释器执行流程是根据程序计数器中记录的下一条指令所在位置来执行

物理实现:

  • 利用CPU中的寄存器

    特点:

  1. 线程私有

    一条线程拥有一个程序计数器

  2. 不会存在内存溢出(JVM规范规定)

  3. 虚拟机栈

    image20230822101155898

  • 一个虚拟机栈对应一个线程,栈表示一段内存空间

  • 栈中存放的元素 —— 栈帧

    每个线程只能有一个活动栈帧,表示当前执行的方法

  • 栈帧 —— 表示一次方法调用需要的内存空间

    内部可能有的信息:参数、局部变量、返回地址...

注意:

  1. 垃圾回收并不涉及栈内存

    由于方法调用完毕后栈内存的信息自动弹出,弹出过程就是简单的毁灭,不必动用GC

  2. 栈内存分配越大越好吗?

    一个线程需要一个栈内存,内存分配越大,能同时启动的线程就越少;并且一般的栈内存足够方法调用存储信息用。

  3. 方法内的局部变量是否线程安全?

    变量的线程安全 —— 一个变量对此线程是共享的,还是私有的 ——

    对于某个变量的访问和操作不会引发数据不一致、不正确或者异常的问题

    旨在确保多个线程可以同时访问共享变量而不会导致意外的错误或不确定的结果

如果方法内局部变量没有逃离方法的作用范围 | 外界无法访问到 | 不是参数传入,也不是返回值 | 是纯粹的内部变量,它就是线程安全的

--- 方法内无法定义static,只能引用static,而对static变量的引用也是线程不安全的

StackOverFlow !

栈溢出原因:

  1. 栈帧过多 —— 大概率来自方法递归调用
  2. 栈帧过大 —— 情况较少

可以显示设置栈内存大小,不然有默认值——不同操作系统设置不同,一般为1m,win会根据计算机虚拟内存进行调整

image20230822161130881

栈溢出情况举例 —— 对象转JSON导致的循环引用

image20230822161845253

image20230822161853966

隐式的递归——两方法来回调用

线程运行诊断

定位(windows下):

  • tasklist定位哪个线程的内存占用过高

  • 资源监视器中定位哪个线程CPU是用来过高

  • jstack 进程id

    可以查看进行下各个线程

    by the way jstack是cmd命令且需要参数,于是不能直接启动jstack.cmd,而是在其目录下打开cmd,再带参数启动

  1. 本地方法栈

    image20230822171304540

    本地方法:

    由于Java本身有限制,不能直接和底层操作系统联通,而需要通过C/CPP本地方法,与操作系统底层提供的API联通

    本地方法栈:留给调用本地方法的内存空间

  2. 堆:

    image20230822171621020

    堆:通过new关键字,创建的对象都会使用堆内存

    特点:

    • 是线程共享的,堆中的对象都需要考虑线程安全问题
    • 堆具有垃圾回收机制
堆内存溢出

垃圾回收机制无能为力——堆中的对象都是有用的

public static void main(String[] args) {
        List<String> stringList = new ArrayList<>();
        String a = "hello";
        Integer num = 0;
        while (true){
            stringList.add(a);
            a = a+a;
            num++;
            System.out.println(num);
        }
    }

image20230822172547655

  • 更改默认堆内存大小(将堆内存设置较小易于排查潜在溢出)

    image20230822172858500

堆内存诊断

工具

  1. jps

    用于查看当前系统中有哪些Java进程

  2. jmap

    用于查看堆内存的占用情况

    jmap -heap 进程id

  3. Jconsole

    图形界面,多功能检测工具——可以查看之前的一切,且是实时更新的

    但是有限制——需要提前知道要检测哪个进程——jps+jmap

  4. JvisualVM

    终极杀人魔火云邪神

    方法:连接到对应pid的线程--堆转储Heap Dump对某一时刻的堆内存进行切片

    ​ 即可查看此时堆中任何对象,对象的大小。

方法区

方法区是所有线程共享的区域,存储和类的结构相关的信息----such as 属性(field),成员方法和构造方法,运行时常量池。

方法区在虚拟机启动时创建。

方法区在逻辑上是堆的组成部分,但不同的JVM实现的方法区的位置不同

例如:hotspot在JDK1.8之前的永久代模式方法区属于堆,但JDK1.8之后的元空间模式则不属于。

image20230822194708668

image20230822194729398

方法区内存溢出

  • 1.8以前永久代模式可能会内存溢出(当内存中加载了太多类的时候)
  • 1.8之后元空间和物理内存空间绑定,一般不会溢出

类加载代码:

image20230822195427609

实际开发中,并非手动加载多个类,但框架使用了许多动态字节码反编译生成类技术。

运行时常量池

引理:类反编译成可读的文本形式(本质还是二进制字节码)

  • 通过javap反编译工具操作.class字节码文件

  • 得知——二进制字节码文件有三个组成部分:

    1. 类的基本信息

      大致内含:

      最后修改时间、签名、类名、类修饰符、Java版本、继承/实现关系、字段/方法数

    2. 常量池

      作用:给下面的方法提供一下常量符号

      类方法用Java指令的写法是:

      做什么 常量符号(参数)

      其中,无论是类,类中某一方法,字符串常量...都在常量池中定义

      常量池是一个k-v表

      在类方法中通过k完成对常量的调用

  1. 类方法定义

    方法中具体实现就是Java指令(程序计数器的目标)

  • 运行时常量池——在运行时将常量池加载到内存,并将k地址引用变为真实地址

StringTable

  • 字符串加载流程:

    1. 编译到字节码文件时,字符串都进入运行时常量池

    2. 在Java指令执行到调用某一字符串时:

      1. 查看StringTable中有无此字符串对象

      2. 如果有则引用,如果没有则将字符串创建对象并加入StringTable

        (此处创建对象就是在堆中开辟空间,将对象的引用地址加入哈希表)

> 本质:懒加载——用到再加载
  • 例题:

    1. --

      false

    2. --

      true

      归纳:字符串如果是由字面量拼接得到,则在字节码中就是对StringTable的引用,但如果是引用变量拼接,由于变量在之后执行还可变,则只能在运行期间动态生成新字符串对象

    3. --

      通过new String() + new String()得到的结果只是对象,而非加入StringTable中

      intern()方法试图将一个字符串对象加入到串池中,并返回串池中的引用对象

    4. --

  • StringTable位置

    Java1.6 版本中,StringTable在方法区(永久代中),但在1.8中转移到堆中

    因为程序对字符串的引用很频繁,而堆的垃圾回收机制比在方法区更灵活/频繁,不容易造成内存过满。

  • StringTable的垃圾回收

    对于未被引用的字符串对象,也会进行垃圾回收

    当内存占用较大,接近运行时常量池最大内存时,会自动执行一次垃圾回收

  • StringTable 性能调优

    由于StringTable底层按照哈希表完成,哈希表的调优取决于桶的多少

    ——桶过少会加大查找时间,通过多导致元素较分散,内存率使用不高,但查找会快。

设置-XX:StringTableSize=xxx

默认桶的个数是60013,桶过少导致链表过长,由于经常需要对StringTable查找有无某一元素,所以导致耗时。

  • 性能优化业务场景

    当有大量字符串需要加载到内存,而其中又有大量重复(身份证上所属籍贯...)

    可以将获取到的字符串用intern()入池,返回的便是池中引用的对象

    如果不入池,很可能导致字面量相同的String对象占满堆内存

    入池后,仅仅不同的字符串占用StringTable中的内存

直接内存

不属于JVM堆内存,而是属于系统的可用物理内存

特点:

  1. 允许Java程序通过本地方法调用直接分配内存,而无序在Java堆上进行对象分配

    使得在IO操作中性能很高

  2. 不受JVM垃圾回收机制管控,因此需要程序员来释放内存

  3. 在NIO中广泛使用

  • 老版本读写操作:

    问题在于:数据缓存了两份

  • 利用直接内存:

垃圾回收

  1. 如何判断对象可以回收

    算法:

    1. 引用计数法:

      当一个对象被引用时,其计数器+1,当其计数器为0时表示可以被回收

      弊端:

      无法识别循环引用问题:

    2. 可达性分析算法:

      首先:明确一系列根对象(肯定不能被回收的对象),其次,扫描全堆,如果被根对象引用/能够沿着根对象为起点的链引用,则拒绝回收,否则进行回收。

      Question1:什么对象是根对象(GC Root)

      可通过工具查看当下堆中有哪些根对象

      eclipse的MemoryAnalyzer

      是一个Java堆分析器,可以帮助查找内存泄漏并减少内存消耗。

      方法:

      1. 通过jmap获取堆转储(Heap Dump:是Java进程在某个时刻的快照,保存了Java对象和类的信息 通常在获取Dump前进行一次GC)

      1. MAT打开bin文件开始分析
  2. Java中的四种引用:

    1. 强引用(之前所见所有引用)
    2. 软引用
    3. 弱引用
    4. 虚引用
    5. 终结器引用

强引用直接引用对象,其他引用类型(软引用、弱引用、虚引用)都是通过间接方式引用对象,引用本身也是普通的Java对象,持有对其他对象的引用

这些引用类型的只要目的是为了影响对象的生命周期和垃圾回收行为

引用队列:

用于管理软弱虚引用对象(当其引用的对象被断开后,普通引用对象即加入引用队列)

主要作用是:当被引用的对象被垃圾回收时,通知程序或执行一系列对象被回收后执行的回调函数(钩子)。

虚引用的典型用法:

  1. 当操作直接内存时,创建了一个ByteBuffer类对象,并用虚引用对象Cleaner引用ByteBuffer。

  2. 由于当垃圾回收掉ByteBuffer时并不彻底,并没有释放掉直接内存

  3. 释放直接内存的步骤就交给虚引用对象Cleaner

    虚引用在断开ByteBuffer后加入到引用队列,引用队列负责执行此虚引用的回调——根据其内存储的直接引用地址,完成内存的释放。

终结器引用:

是某个对象被回收时的回调(钩子)方法

在某个对象需要被回收前,其终结器引用会被加入到引用队列中,当引用队列通过这个终结器引用执行了引用对象的finalize()方法后,引用和引用对象双双被清理。

但据说不推荐finalize()方法

软引用:

业务场景:

当某些对象虽引用了,但由于其占用内存太大了,将其保存在内存中不如重新读取一遍要好,于是用软引用表示可以在需要被回收的时候可以垃圾回收。

  • 如果List直接存储byte[]则为强引用

  • 上图中,List对SoftReference为强引用,SoftReference对实际对象为软引用

  • 软引用好处:

    可以通过触发的垃圾回收机制清理出某些内存,保证程序正确执行

  • 软引用坏处:

    被清理的引用就为null了

软引用+引用队列示例:

  1. 给软引用绑定引用队列

    在软引用关联的对象被回收时,自动将软引用加入到queue中

  2. 可以手写对queue的操作方法完成回调。

垃圾回收算法

JVM在不同情况下使用不同的回收算法 —— 分代回收机制

  1. 标记清除算法:

    一阶段:将没有引用的对象标记出来

    二阶段:将垃圾对象占用的空间释放出来(标记为可覆盖)

    优点:快

    确定:容易产生内存碎片 —— 由于清除操作并没有对内存空间进行整理,而只是标识空白空间可以被重写,容易导致放不下更大的对象

  2. 标记整理算法:

    和标记清除算法的区别在于:三阶段:整理内存

    缺点:整理过程较为复杂,导致垃圾回收速度较慢

  3. 复制算法:

    一阶段:标记可以被回收的垃圾

    二阶段:需要一块大小相同的内存空间(to),将需要保留的对象从老空间(from)复制到新空间中,期间完成整理

    三阶段:对from进行彻底的清理

    四阶段:交换to和from

    缺点:需要一块同等大小的内存

分代回收机制:

实现:

堆内存分为:新生代和老年代

新生代又分出伊甸园,幸存区From,幸存区To

分区的原因——不同区存放不同类型的对象——实行不同的垃圾回收机制

例:

老年代存放常用的、更有价值的对象,不经常垃圾回收

新生代存放朝生夕死的迭代很快的对象,经常垃圾回收

创建对象的过程:

  1. 首先进入伊甸园区

  2. 当伊甸园占满时,触发一次Minor GC(小垃圾回收)

    将伊甸园和From区的保留的对象复制到To区

    清理伊甸园和From区

    交换From和To

    本质上是让伊甸园做存放对象的一阶缓存,From做存放上次没清理掉的对象的二阶缓存,To是一段用于复制的中介空间

    ATTENTION:

    minor gc会引发 stop the world ,暂停其他用户的线程,等垃圾回收结束时,用户线程才恢复运行(由于复制过程导致对象的引用地址发生改编)

  3. 新生代 --> 老年代

    情况一:

    Minor GC没有清理掉的对象(进入From的)寿命+1,当寿命达到某一阈值(最大是15,因为标记此值的表头占4bit),此对象进入(晋升)老年代。

    情况二:

    如果Survivor空间不足以容纳存活的对象,一部分对象会被晋升到老年代。

  4. 当某一时刻:老年代的空间不足(由于老年代像是新生代的后备空间),于是调用Full GC 对全堆(新生代/老年代)进行垃圾回收。

    full GC的Stop the world时间更长,因为要对全堆进行垃圾回收,但如果full GC也空不出空间来,则触发out of memory异常

据说full GC的垃圾回收机制算法

垃圾回收过程


垃圾回收器:

  1. 串行的垃圾回收器

    底层:

    单线程的垃圾回收器,回收垃圾时stop the world

    使用场景:

    堆内存较小,适合个人电脑

  2. 吞吐量优先

    • 考虑因素:程序运行单位时间内,STW的时间占比最短(考虑程序整体中的垃圾回收时间)

    底层:多线程

    使用场景:

    堆内存较大,适合多核CPU(单核的话相当于多个线程轮流清理同一个内存,效果和串行一致)

  3. 响应时间优先

    • 考虑因素:尽可能使单次STW时间更短(不计程序整体执行几次GC,仅最小化单次GC时间)

    底层:多线程

    使用场景:

    堆内存较大,适合多核CPU(same)

串行垃圾回收器

启动命令:

执行流程:

吞吐量优先的垃圾回收器(默认使用)

启动命令:(默认启动)

执行流程:

回收垃圾的线程和CPU核数相关

流程描述:

  1. 正常状态下四个CPU同时运行
  2. 开启垃圾回收
  3. 所有CPU启动垃圾回收线程,其他线程进入阻塞,共同清理同一块堆内存
  • 手动控制线程数:

        

  • 开启自动调整大小策略——堆中各个部分大小、晋升的阈值

  • 设置目标——垃圾回收占整个程序的占比

    如果要占比低,往往需要将堆调大,总体减少垃圾回收的次数

  • 设置目标——最大暂停的毫秒数

    如果要减少STW时间,往往需要将堆调小,垃圾回收扫描的空间减少

响应时间优先的垃圾回收器

开启:

并发Concurrent 标记清除MarkSweep

并发:

    - 可以和用户线程并发执行

并行:

    - 其他用户线程必须STW

工作流程:

  1. 初始标记——简单标标,需要STW
  2. 并发标记——在初始标记的基础上实现并发标记
  3. 重新标记——由于在并发标记阶段,其他线程可能对内存中对象有所调整,故而需要整体快速重来一遍
  4. 并发清理

感觉整个工作流程都是为了能够并发标记而前后铺垫,而之所以能够并发清理,估计是已经确定某些对象没有用了,也就线程安全了。

由于前后两个需要STW的阶段时间很短,所以叫响应时间优先的垃圾回收器

参数:

第一个是并行垃圾回收线程,设置为CPU核数,第二个是并发垃圾回收线程,此线程进行并发的垃圾回收,其他线程依旧完成用户工作

问题:

  1. 占用了一个CPU,导致对程序运行效率(吞吐量)有所影响

  2. 并发清理无法清理并发时产生的垃圾(浮动垃圾),故而需要将其保留至下一次GC

    新问题是:

    既然知道并发清理时会有垃圾产生,就要事先为其预留一定内存,防止内存泄漏

    于是就不能和之前的垃圾回收模式一样在内存不足时进行GC,而是要提前GC

    参数设置:(根据内存占比阈值)

  3. 由于并发执行中使用的是标记清除算法,不能包含整理(应该是防止影响别的线程),于是可能造成内存碎片不够用——导致降级成串行回收算法(需要先标记整理)。

    如此一来时间较长

G1垃圾回收器

特点:

  • 同时注重吞吐量和低延迟,是并行算法和并发算法的集成
  • 适合超大的堆内存,算法会将堆划分成多个大小相等的Region,每个Region相当于小堆
  • 整体上是标记+整理算法,两个区域之间是复制算法

启动:

JDK9之后默认使用G1,之前版本需要启动

参数设置:

流程:

  1. 三个阶段:

  2. 一阶段Young Collection 新生代垃圾回收

    1. G1将整个堆内存划分成许多Region,分别作为伊甸园区或幸存者区或老年代

      一阶段内存对象首先填充伊甸园区

    2. 当伊甸园区内存满后,通过复制算法做类似Minor GC

    3. 当幸存区内存不够,或者年龄达到阈值,晋升!

  3. 二阶段:标记

    • 在Young GC时会进行GC Root的初始标记

    • 当老年代占用堆空间的比例达到阈值时,进行并发标记(不会STW)

      阈值由参数决定

  4. 三阶段:混合收集

    对伊甸园区,幸存者区,老年代进行全面的垃圾回收

    • 最终标记(由于并发过程其他线程产生了垃圾)

    • 拷贝存活

      • E到S,进行新生代的垃圾回收

      • O到O,通过标记-整理算法(期间涉及区到区的复制),回收老年代的垃圾

        注意:

        • 并非所有老年代都参与此次的垃圾回收,而是G1会识别最该被回收的垃圾,考虑到有最长时间的限制而不能全部进行回收(复制过程较为耗时)

Minor GC 和 Full GC

对不同的垃圾回收器,两个垃圾回收模式工作于不同区域

  1. 串行GC

  2. 并行GC

  3. CMS(并发GC)

    只有当并发垃圾回收失败时,退化成串行垃圾回收,才会执行Full GC

    并发失败:

    1. 并发清理阶段中:如果在并发清理期间,新生成的垃圾增加太快,快过垃圾回收的时间,将导致老年代的空间耗尽,从而促发Full GC。
    2. 由于缺少内存整理而导致的内存碎片无法放下新对象而导致的full GC
  4. G1

    也是只有当并发垃圾回收失败时,退化成串行垃圾回收,才会执行Full GC

    并发失败:

    • 并发清理阶段中:如果在并发清理期间,新生成的垃圾增加太快,快过垃圾回收的时间,将导致老年代的空间耗尽,从而促发Full GC。
    • 由于G1的垃圾回收算法是在Region之间复制,复制过程中完成了内存整理,从而不会产生过多的内存碎片。

新生代跨代引用

由于新生代Minor GC时需要判断对象是否被引用,于是需要找出GC root,而GC Root有部分在老年代,每次young GC时都扫描老年代效率很低

于是采用 Card Table卡表技术,将老年代划分为多个区域存储对象,如果一个区域内的对象引用了新生代的对象,则标记为脏卡

在新生代中设置Remember Set 记录有哪些脏卡引用了这个新生代,于是只要遍历脏卡,扫描出响应的GC Root即可。

细说重标记

原委回顾:

  • 在并发标记过程中,垃圾回收线程标记过的对象可能在之后又被用户线程调整引用(决定回收还是不回收),于是容易导致错误地将对象回收/未回收。

做法:

  • 设置回调——对于标记线程之后的对象,如果调整其引用关系的话要触发一个回调函数(钩子)——写屏障技术

    写屏障将被引用的对象加入到队列当中,并将此对象变成灰色(标记为未处理)

    重标记的过程就是对队列中的对象进行重新判断处理

GC调优

技术要求:VM参数设置,相关工具

使用-XX:+PrintFlagFinal打印所有以-XX格式的参数

JVM参数

深入理解 Java 虚拟机(第二弹) - 常用 vm 参数分析_vm 引数该填什么_hello-java-maker的博客-CSDN博客

分类:

  1. 标准参数(-):所有JVM都必须实现的功能
  2. 非标准参数(-X):并非保证所有JVM都实现
  3. 非Stable参数(-XX):各个JVM实现有所不同
  • 标准参数

    通过命令行中 java -help可查看

    1. -client

    2. -server

      以client/server模式启动JVM,C启动速度快,但运行时性能和内存管理效率不高,S是默认模式,适合生产环境,适用于服务器

    3. -classpath

      通知JVM类搜索路径?

    4. -DpropertyName=value

      定义全局属性值

    5. -verbose

      查询GC问题常用命令

      另外,控制台输出GC信息还可以使用如下命令:

      在JVM的启动参数中加入

      -XX:+PrintGC

      -XX:+PrintGCDetails

      -XX:+PrintGCTimeStamps

      -XX:+PrintGCApplicationStoppedTime

      按照参数的顺序分别输出GC的简要信息,GC的详细信息、GC的时间信息及GC造成的应用暂停的时间.

非标准参数

  1. -Xmn

    设置新生代内存大小的最大值

  2. -Xms

    设置初始堆大小,也就是堆大小的最小值

  3. -Xmx

    设置堆的最大值

  4. 另外,官方默认的配置为**年老代大小:年轻代大小=2:1**左右,使用-XX:NewRatio可以设置年老代和年轻代之比,例如,-XX:NewRatio=4,表示年老代:年轻代=4:1

  5. -Xss

    设置每个线程的栈内存

  6. -Xprof

    跟踪正在运行的程序,并将跟踪数据在标准输出输出

  7. -Xint

    设置JVM为解释模式(interpret mode)

    解释模式会强制JVM逐行执行所有的字节码,运行速度较慢(没有进行JIT编译)

  8. -Xcomp

    设置JVM为编译模式(compile mode)

    JVM在第一次使用时就会把所有字节码编译成本地代码(启动JIT)

  9. -Xmixed

    设置JVM为混合模式(默认)

    将解释模式和编译模式进行混合使用(由JVM决定使用哪个)

非 Stable 参数

分类:

  1. 性能参数:

    用于JVM的性能调优和内存分配控制

  2. 行为参数:

    用于改变JVM的基础行为,比如GC的方式和算法的选择

  3. 调试参数:

    用于监控、打印、输出等JVM参数,以显示详细信息

调优的领域

  1. 关于内存的垃圾回收调优
  2. 关于锁竞争的调优(并发)
  3. 关于CPU占用的调优
  4. 关于IO的调优
  5. 关于数据库调优
  6. 关于缓存和预取策略的调优
  7. 关于分布式系统的调优

确定优化的目标

  1. 高吞吐量

    表示在特定时间内完成应用程序业务逻辑与垃圾回收操作的比例(即允许单次长时间垃圾回收,但只关注总体上用户线程执行的速度)

    因为可能会导致较长时间的STW,所以适合不关心时延的项目

    比如:科学计算

    适合:ParallelGC


  2. 低延迟

    关注于降低时延,尽快完成响应(即需要降低单次垃圾回收的最长停顿时间,意味着内存不会太大,吞吐量也就不会太大)

    适合于互联网行业的高可用,低延迟项目

    适合:CMS,G1,ZGC

优化的考虑——最快的GC是不发生GC

考虑代码的写法能否避免内存频繁地垃圾回收

  1. 数据是不是太多了

    例如:resultSet = statement.executeQuery("select * from user")

    将一张表的全部数据全都加载到了Java内存中,势必导致许多GC

    解决方法是 limit n 避免无效数据进入内存

  2. 数据表示是否太过臃肿

    例如:

    1. 也要求从数据库中获取数据避免查询没用的

    2. 对Java对象的大小初步认知

      一个最小的对象:内存占16byte

      基本类型int:4byte

      包装类型Integer:24byte

      所以加入将所有的包装类型下降到基本类型,也可以释放一部分内存

  3. 是否存在内存泄漏

    例如:static Map map = ...

    一直向声明的静态集合中添加数据,但对之前添加的无用数据不做清理

    做法:

    1. 软引用,弱引用

    2. 用第三方缓存实现对需要长期存在的数据的保存

      比如:redis等,不占用JVM内存,同时拥有自己的垃圾过期算法

正式从GC考虑调优

新生代调优

新生代的特点:

  1. 所有的 new 操作的内存分配非常廉价

    TLAB thread-local allocation buffer

    表示伊甸园区为每个线程分配了一块内存用于创建其对象,于是不必考虑多个线程之间冲突的问题

  2. 死亡对象的回收代价是0

  3. 大部分对象是用过即死的

  4. Minor GC的时间远远低于Full GC

    原因:处理的对象区域更小,执行的操作相对简单(Full GC是全部GC,不光是老年代)

    时间大概相差1-2个数量级

如何调优新生代

Question-1:新生代的内存越大越好吗?

  • 首先内存较小会导致minor GC过于频繁

  • 但新生代内存较大会挤掉老年代的内存——老年代比新生代更怕内存不足——老年代会触发full GC。

    并且新生代内存如果过大,空出许多本不需要的区域,垃圾回收起来也反而更慢。

  • 折中:新生代占整个堆的25%--50%

    新生代的内存只要能够容纳所有的【并发量*(请求-响应)】的数据

如何调优幸存区

幸存区里是两类对象:当前活跃的对象(幸存者,但即将被回收)+ 需要晋升的对象

  • 需要将即将被回收的对象保留在幸存区,又需要将需要晋升的对象速速晋升到老年代

    原因:

    • 如果一个马上不用的对象被粗略的晋升到老年代,会占用老年代的内存空间,导致full GC更加频繁。而只有在下次full GC时才会被清理。
    • 如果一个常用对象一直不被晋升,其在新生代中一直minor GC却一直留下,违背了老年代提出热点对象的初衷。
  • 所以晋升阈值需要配置得当

    通过查看幸存区当下的对象(大小,年龄)来实时调整

老年代调优

以CMS为例

  • 老年代的内存总体而言越大越好

    原因:CMS中,并发清理时如果清理速度不及其他线程垃圾产生速度就会退化到full GC串行回收,于是会预留一定空间给浮动垃圾。预留空间和总空间大小相关

  • 关于预留空间的大小设置(占整个老年代比例)

    常见设置:75%--80%

回来吧我的JVM

  1. 属于JVM层面的问题

    1. 运行着的线上系统突然卡死,系统无法访问,甚至直接OOM
    2. 线上项目频繁GC,导致系统时延高
    3. 新项目上线,要设置各种JVM参数
    4. 怀疑项目出现内存泄漏,但不知道如何找出泄漏的代码

学习JVM的目的:

生产中的性能调优 --> 需要性能监控方法(命令行/可视化工具) --> 需要看得懂监控内容和指标-明白垃圾回收算法和垃圾回收器 --> 垃圾回收是基于内存的-明白内存的结构和分配 -->内存中的数据来自class文件-需要了解class文件的加载过程,解释过程

一:字节码

Q1:字节码文件时跨平台的吗?

Java虚拟机不和包括Java在内的任何语言绑定,它只和.class文件这种特定的二进制文件格式所关联

无论使用任何语言进行软件开发,只要能将源文件编译成class文件,就可以在JVM上执行

可以说统一而强大的class文件结构是JVM的基石、桥梁

Q2:.class文件里面是什么?

源代码经过编译器之后会生成字节码文件,是一种二进制的类文件(不给人读)。内容是JVM指令

Q3:如何理解Java是半解释半编译型语言?

Java将所有.java文件通过前端编译器编译成字节码文件后,具体执行是由两种方式

混合执行(Mixed-mode Execution)

  1. 解释执行(Interpreted Execution)

    源代码逐行被翻译成机器码,并且翻译过程是在运行是逐行进行的 -- 程序是逐行进行的

    • 解释执行的速度相对较慢
    • 每次运行都需要重新解释
  2. 即时编译(Just-In-Time Compilation)

    JIT编译器在程序运行时将整个或部分字节码一次性编译成机器码,这种编译发生在程序执行之前(会导致启动稍慢)

    一旦代码被编译成机器码,就会保存在内存中,便于之后的执行 -- 不需要每次执行都进行翻译

  • Java结合两方的优势,在程序启动时为了迅速执行代码使用解释器,同时在运行时会将热点代码由JIT做缓存,确保执行效率高(但最初还未记录热点代码时【预热阶段】速度较慢)

Q4:介绍一下生成class文件的编译器:

此编译器的作用就是将.java源代码编译生成.class字节码文件

常见的组件有javac,ECJ(Eclipse Compiler for Java)| hotspot VM可以切换使用二者

  • javac:默认使用,但每次编译都会从头开始

  • ECJ:ECJ编译器采取的编译方案是:增量式编译 -- 把未编译的部分的源码逐行编译

    因此回避javac快很多

为什么不选ECJ?

  • 似乎因为Javac是Oracle JDK和OpenJDK提供的标准编译器,和其他部分更兼容吧

    或者因为javac就在JDK中,使用也比较简单

Q5:前端编译器将.java编译成.class文件的流程:

Q6:哪些类型对应有Class对象?

类加载器将字节码文件转化成在方法区中的类

方法区中就存储着类的结构信息 - 字段信息/方法信息/构造方法/常量池...

方法区所说的这些类对象,就是代码中 Class clazz = int.class;得到的clazz

有哪些类有Class对象?

  • class :

    外部类、内部类、匿名内部类

  • interface

  • [] 数组

  • enum 枚举

  • annotation 注解

  • primitive type 基本数据类型

  • void

Q7:什么是字节码指令(byte code)?

JVM的指令由一个字节长度的、代表都中特定操作含义的操作码 以及跟随其后的0到多个操作数 所构成

是字节码文件中的具体指令 ---- 就是.class可视化后的一部分

是虚拟机能够理解和执行的操作码

Q8:关于包装类对象的缓存问题:

Integer内部类有一个IntergerCache,内部有一个数组[-128,127]表示热点数的缓存(启动时完成256个Integer的new)

创建对象时调用valueOf方法,过程中判断是否在池子中

怎么看到这个赋值过程怎么执行的?

  • 查看字节码文件中的字节码指令
  • Others:

为什么Float和Double没有缓存?

  • 因为没有热点数据 / []

Q9:如何解读字节码文件?

  1. 按照16进制打开文件并读取,但人不可读
  2. 用JClassLib插件读取,可获取到字节码指令

Q10:class文件结构是什么?

由于字节码文件是纯二进制数据,所以部分内容要么定长度,要么前置元数据标明长度

  • 魔数

  • Class文件版本

    2 bytes 副版本 2bytes 主版本

    主版本表示JDK版本,数值45/46/.../52/53/...对应JDK1.8,1.9

    存在在.class文件表示:JVM只能解释运行之前版本(向下兼容),如果版本数过高则拒绝执行

  • 常量池计数器

    2 bytes表示常量池容量计数值

    • 计数值从1开始,1表示有0项

      刻意把第0项常量空出来,是为了满足后面某些指向常量池的索引值的数据在特定情况下需要表达:不引用任何一个常量池项目的含义,此时索引值是0;

  • 常量池

    存放所有常量

    • 常量池是Class文件中的资源仓库,Class文件中其他结构和常量池关联很多

    • 常量池也是Class文件中占用空间最大的项目

    • Q10.1:解释一下常量池

      常量池中的数据分为字面量和符号引用

      Q10.1.1:说明一下符号引用和直接引用的关系

      • 符号引用:在还未加载的.class文件中,方法或属性对常量池中常量的引用只是标记了其在常量池内的偏移量;

        即通过常量池找到某个常量的位置

      • 直接引用:指直接指向内存中目标的引用

.class文件中保留着符号引用,在将Class文件加载到内存中时,通过符号引用获取到其真实内存地址,才能实现直接引用
  • 访问标识(标志)

    2 bytes,用于表示类/接口层面的访问信息

    例如 是类还是接口、是public还是什么、是否是abstract,是否被声明为final

  • 类索引、父类索引、接口计数器、接口索引集合

  • 字段表集合

    由于字段叫什么、字段是什么类型、都是无法固定的,所以只能将其放入常量池、再引用常量池中

    所以每个字段在这里的结构都是一样的,可以直接对应到具体内容

    • 字段计数器

    • 对于每个字段:

      • 标识符
      • 访问修饰符
      • 是否是static
      • 是否是final
      • 是否是volatile
      • 是否序列化
      • 字段数据类型
      • 字段名称
  • 方法表集合

  • 属性表集合