JVM总结

JVM总结

JVM概述

JVM 的主要组成部分及其作用?

在这里插入图片描述

  • 类装载子系统: 将字节码文件加载到运行时数据区中,并对数据进行处理,最终形成可以直接被jvm直接使用的Java类型
  • 执行引擎:将字节码翻译成底层系统指令,再交由 CPU 去执行
  • 本地方法接口:与本地方法库交互,是其他编程语言交互的接口
  • 本地方法库:一些使用其他编程语言实现的方法,供虚拟机使用
  • 运行时数据区:JVM内存,执行Java程序中的内存

JVM内存

运行时数据区的结构是什么样的

主要分为五大部分
在这里插入图片描述

线程私有:本地方法栈、程序计数器、虚拟机栈
线程共享:堆、方法区(元空间)

  • 程序计数器:线程私有的,是一块很小的内存空间,作为当前线程所执行的字节码的行号指示器,用来存储指向下一条指令的地址

  • 虚拟机栈:线程私有的,描述的是Java方法执行的线程内存模型,每个方法执行的时候都会创建一个栈帧,用于存储局部变量表、操作数栈、动态连接和方法出口等信息,一个方法的执行到结束对应着一个栈帧在虚拟机栈中入栈到出栈的过程。当线程请求的栈深度超过了虚拟机允许的最大深度时,会抛出StackOverFlowError;线程申请栈空间失败会抛出OutOfMemoryError异常

  • 本地方法栈:线程私有的,描述的是本地方法执行的线程内存模型,当线程请求的栈深度超过了虚拟机允许的最大深度时,会抛出StackOverFlowError;线程申请栈空间失败会抛出OutOfMemoryError异常

  • 堆:java堆是所有线程共享的一块内存,几乎所有对象的实例和数组都要在堆上分配内存,因此该区域经常发生垃圾回收的操作。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

    • 如果对象经过逃逸分析,某些方法中的对象引用没有被返回或者未被外面使用,那么对象可以直接再栈上分配内存
  • 方法区:存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码数据。运行时常量池对应字节码文件中的常量池表

    • JDK1.6及之前,有永久代,静态变量存储在永久代上
    • JDK1.7,逐步去永久代,字符串常量池、静态变量保存在堆中
    • JDK1.8,无永久代,在本地内存建立了元空间,但运行时常量池、字符串常量池和静态变量保存在堆中;
  • 直接内存
    直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。

    • JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道(Channel)与缓存区(Buffer)的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。

    • 本机直接内存的分配不会受到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

什么是JVM内存模型?

Java 内存模型(下文简称 JMM)就是在底层处理器内存模型的基础上,定义自己的多线程访问规则。它明确指定了一组排序规则,来保证线程间的可见性。

这一组规则被称为 Happens-Before, JMM 规定,要想保证 B 操作能够看到 A 操作的结果(无论它们是否在同一个线程),那么 A 和 B 之间必须满足 Happens-Before 关系:

  • 单线程规则:一个线程中的每个动作都 happens-before 该线程中后续的每个动作
  • 监视器锁定规则:监听器的解锁动作 happens-before 后续对这个监听器的锁定动作
  • volatile 变量规则:对 volatile 字段的写入动作 happens-before 后续对这个字段的每个读取动作
  • 线程 start 规则:线程 start() 方法的执行 happens-before 一个启动线程内的任意动作
  • 线程 join 规则:一个线程内的所有动作 happens-before 任意其他线程在该线程 join() 成功返回之前
  • 传递性:如果 A happens-before B, 且 B happens-before C, 那么 A happens-before C

怎么理解 happens-before 呢?如果按字面意思,比如第二个规则,线程(不管是不是同一个)的解锁动作发生在锁定之前?这明显不对。happens-before 也是为了保证可见性,比如那个解锁和加锁的动作,可以这样理解,线程1释放锁退出同步块,线程2加锁进入同步块,那么线程2就能看见线程1对共享对象修改的结果。
图片

Java 提供了几种语言结构,包括 volatile, final 和 synchronized, 它们旨在帮助程序员向编译器描述程序的并发要求,其中:

  • volatile - 保证可见性有序性
  • synchronized - 保证可见性和有序性; 通过**管程(Monitor)**保证一组动作的原子性
  • final - 通过禁止在构造函数初始化和给 final 字段赋值这两个动作的重排序,保证可见性(如果 this 引用逃逸就不好说可见性了)

编译器在遇到这些关键字时,会插入相应的内存屏障,保证语义的正确性。

有一点需要注意的是,synchronized 不保证同步块内的代码禁止重排序,因为它通过锁保证同一时刻只有一个线程访问同步块(或临界区),也就是说同步块的代码只需满足 as-if-serial 语义 - 只要单线程的执行结果不改变,可以进行重排序。

所以说,Java 内存模型描述的是多线程对共享内存修改后彼此之间的可见性,另外,还确保正确同步的 Java 代码可以在不同体系结构的处理器上正确运行。

堆中所有区域都是共享的吗?TLAB

不是,有线程私有的分配缓冲区TLAB,解决了并发环境下,当我们为对象分配地址时,可能多个线程操作同一个地址。

  • TLAB内存不够则通过加锁的方式确保线程安全
  • 针对于分配动作,TLAB是线程私有的,对于读取使用垃圾回收等操作是线程共享的

说一下堆栈的区别?

  • 物理地址:

    • 堆的物理地址分配对对象是不连续的。因此性能慢些。在GC的时候也要考虑到不连续的分配,所以有各种算法。比如,标记-消除,复制,标记-压缩,分代(即新生代使用复制算法,老年代使用标记——压缩)
    • 栈使用的是数据结构中的栈,先进后出的原则,物理地址分配是连续的。所以性能快。
  • 内存分别

    • 堆因为是不连续的,所以分配的内存是在运行期确认的,因此大小不固定。一般堆大小远远大于栈。
    • 栈是连续的,所以分配的内存大小要在编译期就确认,大小是固定的。
  • 存放的内容

    • 堆存放的是对象的实例和数组。因此该区更关注的是数据的存储
    • 栈存放:局部变量,操作数栈,动态连接,返回结果。该区更关注的是程序方法的执行。
  • 程序的可见度

    • 堆对于整个应用程序都是共享、可见的。
    • 栈只对于线程是可见的。所以也是线程私有。他的生命周期和线程相同。

什么情况下会发生栈内存溢出?

当线程请求的栈深度超过了虚拟机允许的最大深度时,会抛出StackOverFlowError异常,方法递归调用可能会出现该问题;

调整参数-xss去调整jvm栈的大小

谈谈对 OOM 的认识?如何排查 OOM 的问题?

除了程序计数器,其他内存区域都有 OOM 的风险。

  • 栈一般经常会发生 StackOverflowError,但当线程申请栈空间失败会发生栈的 OOM
  • Java 8 常量池移到堆中,溢出会出 java.lang.OutOfMemoryError: Java heap space,设置最大元空间大小参数无效;
  • 堆内存溢出,报错同上,GC 之后无法在堆中申请内存创建对象就会报错;
  • 方法区 OOM,经常会遇到的是动态生成大量的类、jsp 等;
  • 直接内存 OOM,涉及到 -XX:MaxDirectMemorySize 参数和 Unsafe 对象对内存的申请。

排查 OOM 的方法:

  • 增加两个参数 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof,当 OOM 发生时自动 dump 堆内存信息到指定目录;
  • 同时 jstat 查看监控 JVM 的内存和 GC 情况,先观察问题大概出在什么区域;
  • 使用 MAT 工具载入到 dump 文件,分析大对象的占用情况,比如 HashMap 做缓存未清理,时间长了就会内存溢出,可以改为弱引用

什么是内存泄露?都有哪些情况会产生内存泄露

堆内存中不再使用的对象无法被回收,一直存在的现象。JVM通过可达性分析算法判断一个对象是否可以被回收,长生命周期对象持有短生命周期对象的引用时短生命周期对象就无法被及时回收

工具:MAT

内存泄露类型

  1. static字段引起:static字段生命周期和类的声明周期一致,造成对象无法及时回收
    解决方案:减少静态变量使用,单例模式使用懒加载
  2. 未关闭的资源:创建连接或打开流时,JVM会为这些资源分配内存,没有及时关闭就会消耗内存
    解决方案:在finally块中关闭资源
  3. 不正确的equals()和hashCode():HashMap和HashSet中,元素存入后不能修改key的hashcode()函数,修改会导致无法找到该元素
    解决方案:正确重写equals()和hashCode()
  4. 内部类持有外部类:内部类对象被外部类对象长期持有,外部类对象不会被GC
  5. finalize()方法造成的内存泄露:finalize()方法有问题会造成对象逃出GC
    解决方案:避免重写finalize()方法
  6. 常量字符串造成:部分字符串位于字符串常量池,使用完毕不会回收
  7. 使用ThreadLocal造成内存泄露

谈谈 JVM 中的常量池?

JVM常量池主要分为Class文件常量池运行时常量池全局字符串常量池,以及基本类型包装类对象常量池

  • Class文件常量池:class文件是一组以字节为单位的二进制数据流,在java代码的编译期间,我们编写的java文件就被编译为.class文件格式的二进制数据存放在磁盘中,其中就包括class文件常量池。
  • 运行时常量池:运行时常量池相对于class常量池一大特征就是具有动态性,java规范并不要求常量只能在运行时才产生,也就是说运行时常量池的内容并不全部来自class常量池,在运行时可以通过代码生成常量并将其放入运行时常量池中,这种特性被用的最多的就是String.intern()。
  • 全局字符串常量池:字符串常量池是JVM所维护的一个字符串实例的引用表,在HotSpot VM中,它是一个叫做StringTable的全局表。在字符串常量池中维护的是字符串实例的引用,底层C++实现就是一个Hashtable。这些被维护的引用所指的字符串实例,被称作”被驻留的字符串”或”interned string”或通常所说的”进入了字符串常量池的字符串”。
  • 基本类型包装类对象常量池:Java 基本类型的包装类的大部分都实现了常量池技术,即 Byte,Short,Integer,Long,Character,Boolean;前面 4 种包装类默认创建了数值 [-128,127] 的相应类型的缓存数据,Character 创建了数值在 [0,127] 范围的缓存数据,Boolean 直接返回 True Or False。如果超出对应范围仍然会去创建新的对象。

GC

如何判断一个对象是否存活?

判断一个对象是否存活,分为两种算法1:引用计数法;2:可达性分析算法;

引用计数法:给每一个对象设置一个引用计数器,当有一个地方引用该对象的时候,引用计数器就+1,引用失效时,引用计数器就-1;当引用计数器为0的时候,就说明这个对象没有被引用,也就是垃圾对象,等待回收;缺点:无法解决循环引用的问题,当A引用B,B也引用A的时候,此时AB对象的引用都不为0,此时也就无法垃圾回收,所以一般主流虚拟机都不采用这个方法;

可达性分析法从一个被称为GC Roots的对象向下搜索,如果一个对象到GC Roots没有任何引用链相连接时,说明此对象不可用,在java中可以作为GC Roots的对象有以下几种:

  • 虚拟机栈中引用的对象
  • 方法区类静态属性引用的变量
  • 方法区常量池引用的对象
  • 本地方法栈JNI引用的对象
  • 所有被同步锁持有的对象

但一个对象满足上述条件的时候,不会马上被回收,还需要进行两次标记;

  • 第一次标记:判断当前对象是否有finalize()方法并且该方法没有被执行过,若不存在则标记为垃圾对象,等待回收;若有的话,则进行第二次标记;
  • 第二次标记将当前对象放入F-Queue队列,并生成一个finalize线程去执行该方法,虚拟机不保证该方法一定会被执行,这是因为如果线程执行缓慢或进入了死锁,会导致回收系统的崩溃;如果执行了finalize方法之后仍然没有与GC Roots有直接或者间接的引用,则该对象会被回收;

强引用、软引用、弱引用、虚引用是什么,有什么区别?

  • 强引用,程序代码之中普遍存在的引用赋值“==”,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象,可能导致内存泄漏
  • 软引用,用于维护一些可有可无的对象。通常用来实现内存敏感的缓存,内存溢出前会将这些对象加入回收范围,进行第二次回收(第一次是不可达对象),如果内存仍不够会抛出内存溢出异常
  • 弱引用,相比软引用来说,要更加无用一些,它拥有更短的生命周期,当 JVM 进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。WeakReference 实现
  • 虚引用:不会对对象的生存时间产生影响, 无法通过虚引用获取对象实例,会在该对象回收时收到一个系统通知

软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生

被引用的对象就一定能存活吗?

不一定,看 Reference 类型,软引用在内存不足的时候,即 OOM 前会被回收,弱引用在 GC 时会被回收。

如何判断一个类是无用的类

方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?

判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是 “无用的类” :

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。

垃圾回收中的安全点?

  • 垃圾回收首先要枚举GCRoots,遍历引用链,而引用链可能在不断的变化。安全点就是程序在执行到该点时才能暂停开始GC。
  • 安全点一般选取为长时间执行的程序,如方法调用、循环跳转等
  • HotSpot使用主动式轮询,当程序执行到安全点时,主动轮询中断标志,为真就暂停

Java中的垃圾回收算法有哪些?

java中有四种垃圾回收算法,分别是标记-清除算法、标记-整理算法、复制算法、分代收集算法

标记-清除算法

  • 第一步:利用可达性分析去遍历内存,把存活对象和垃圾对象进行标记;
  • 第二步:再遍历一遍,将所有标记的对象回收掉;
  • 特点:效率不行,标记和清除的效率都不高;标记和清除后会导致内存空间碎片化,碎片太多可能会导致之后程序运行的时候需分配大对象而找不到连续分片而不得不触发一次GC;

标记-整理算法

  • 第一步:利用可达性去遍历内存,把存活对象和垃圾对象进行标记;
  • 第二步:将所有的存活的对象向一端移动,将端边界以外的对象都回收掉;
  • 特点:无空间碎片产生;也不会像复制算法那样需要空白空间;但效率低,需要调整引用地址,移动时要STW

复制算法

  • 将可用内存分为两部分:每次只使用一部分,垃圾回收时将需要保留的对象复制到另一块,而后直接全部清除本块
  • 实现简单,运行高效,不会出现内存碎片;但空间浪费
  • 适用于垃圾多,存活对象少的场景

分代收集算法

  • 根据内存对象的存活周期不同,将内存划分成几块。Hotspot虚拟机分为新生代和老年代
  • 在新生代中,每次收集都会有大量对象死去,所以可以选择”标记-复制“算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集

三色标记法是如何实现的?

  • 白色,代表尚未访问的对象
  • 灰色,代表本对象已访问过,但本对象引用到的其他对象尚未全部访问完
  • 黑色,代表本对象已访问过,且本对象引用到的其他对象全部访问完毕

流程如下

  1. 初始时,所有对象都在白色集合中
  2. 将GCRoots直接引用的对象放入灰色集合
  3. 从灰色集合中获取对象:将本对象引用到的其他全部对象放入灰色集合,本对象放入黑色集合
  4. 重复上述操作直到灰色集合没有对象
  5. 仍在白色集合中的对象就是不可达对象,可以进行回收

有哪几种垃圾回收器,各自的优缺点是什么?

Serial/Serial Old,Parallel Scavenge/Parallel Old, ParNew/CMS, G1

Serial/Serial Old

  • Serial-新生代-复制算法;Serial Old-老年代-标记整理算法
  • 串行回收:只有一个GC线程
  • STW:GC时暂停所有用户线程
  • 一般用于客户端的GC

Parallel Scavenge/Parallel Old

  • Parallel Scavenge-新生代-复制算法;Parallel Old-老年代-标记整理算法
  • 并行回收:多线程并行收集
  • STW:GC时暂停所有用户线程
  • GC自动调节策略;虚拟机会根据系统的运行状态收集性能监控信息,动态设置一些参数,以提供最优停顿时间和最高的吞吐量;吞吐量=运行用户代码时间/(运行用户代码时间+运行垃圾收集时间);

ParNew

  • 新生代-复制算法
  • Serial的多线程版本
  • 并行回收:多线程并行收集
  • STW:GC时暂停所有用户线程

CMS

  • 老年代-标记-清除算法
    • 初始标记:STW,标记GC Roots能直接关联到的对象,速度很快
    • 并发标记:从GC Roots的直接关联对象开始遍历整个对象图,耗时长,不需要停顿用户线程,与垃圾收集器并发运行
    • 重新标记:STW,修正并发标记阶段因用户线程运行导致标记变动的对象的标记记录
    • 并发清除:清理删除掉标记阶段判断的已经死亡的对象,不需要停顿用户线程
  • 低延迟,追求最短的回收停顿时间
  • 并发回收导致CPU资源紧张; 无法清理浮动垃圾;并发失败的风险(必须预留一部分空间供并发回收时的程序运行使用);.由于使用标记-清除算法,内存碎片化(Full GC 时开启内存碎片的合并整理过程)

G1

  • 整体基于标记-整理算法,局部(两个region)基于复制算法

    • 1.初始标记:STW,标记GC Roots能直接关联到的对象,修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象,耗时很短,而且是借用进行Minor GC的时候同步完成的
    • 2.并发标记: 从GC Roots的直接关联对象开始遍历整个对象图,耗时长,与用户线程并发。
    • 3.最终标记:对用户线程做短暂的暂停,处理并发阶段结束后仍有引用变动的对象
    • 4.筛选回收:更新Region数据,对各个Region的回收价值和成本进行排序,根据用户期望的停顿时间进行回收。暂停用户线程
  • G1将整个堆分为大小相等的多个Region(区域),G1跟踪每个区域的垃圾大小,在后台维护一个优先级列表,每次根据允许的收集时间,优先回收价值最大的区域,已达到在有限时间内获取尽可能高的回收效率;

  • 不会产生空间碎片,可以精确地控制停顿;

JVM中一次完整的GC是什么样子的?内存分配与回收策略?

  1. 如果启用了本地线程缓冲,将按照线程优先在 TLAB 上分配
  2. 多数情况,对象都在新生代 Eden 区分配。当 Eden 区分配没有足够的空间进行分配时,虚拟机将会发起一次 Minor GC。如果本次 GC 后还是没有足够的空间,则将启用分配担保机制在老年代中分配内存。
  3. Eden 区GC,这时会采用复制算法,将 Eden 和 from 区一起清理,存活的对象会被复制到 to 区;
    移动一次,对象年龄加 1,对象年龄大于一定阀值会直接移动到老年代。
  4. 大对象直接进入老年代,大对象就是需要大量连续内存空间的对象(比如:字符串、数组),为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。
  5. 老年代满了而无法容纳更多的对象,Minor GC 之后通常就会进行Full GC,Full GC 清理整个内存堆 – 和元空间

Minor GC 和 Full GC 有什么不同呢?

  • Minor GC:只收集新生代的GC。
  • Full GC: 收集整个堆,包括 新生代,老年代,元空间。

Minor GC触发条件: 当Eden区满时,触发Minor GC。

Full GC触发条件:

  • 通过Minor GC后进入老年代的平均大小大于老年代的可用内存。如果发现统计数据说之前Minor GC的平均晋升大小比目前old gen剩余的空间大,则不会触发Minor GC而是转为触发full GC。
  • 老年代空间不够分配新的对象。
  • 由Eden区、From Space区向To Space区复制时,对象大小大于To Space可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。
  • 调用System.gc时,系统建议执行Full GC,但是不必然执行。
  • 元空间已满

介绍下空间分配担保原则?

在这里插入图片描述

对象

对象的创建过程?

在这里插入图片描述

Step1:类加载检查

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

Step2:分配内存

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

内存分配的两种方式
在这里插入图片描述

选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的

内存分配并发问题

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

  • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
  • TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配

Step3:初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

Step4:设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

Step5:执行 init 方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始, 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

< init > 方法是给成员变量赋值,执行构造函数的方法,会先调用父类的该方法
< clinit >方法是类变量赋值+静态代码块,先于< init > 方法

对象的内存布局?

在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头、实例数据和对齐填充。

Hotspot 虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据(哈希码、GC 分代年龄、锁状态标志等等),另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是那个类的实例。

实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

对象的访问定位

建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有① 使用句柄和② 直接指针两种:

句柄: 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;

直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。

这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

类加载

什么是类加载?类加载的过程?

虚拟机把描述类的数据加载到内存里面,并对数据进行校验、解析和初始化,最终变成可以被虚拟机直接使用的class对象;
类的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。其中准备、验证、解析3个部分统称为连接(Linking)。
在这里插入图片描述
加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)

类加载过程如下:

  • 加载,加载分为三步:

    • 1、通过类的全限定性类名获取该类的二进制流;
    • 2、将该二进制流的静态存储结构转为方法区的运行时数据结构;
    • 3、在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
  • 验证:确保Class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机,包括数据格式验证、元数据验证、字节码验证、符号引用验证

  • 准备:为class对象的静态变量分配内存,默认初始化;对常量显式初始化(编译时分配内存)

  • 解析:该阶段主要完成符号引用转化成直接引用;

  • 初始化:执行类构造器方法< clinit >()的过程,该方法是所有类变量的赋值动作和静态代码块中的语句合并而成,该方法多线程下同步加锁

什么是类加载器,常见的类加载器有哪些?

类加载器本身也是一个类,而它的工作就是读取Java字节码文件并将其转换为 java.lang.Class类的一个实例

类加载器分为以下四种:

  • 启动类加载器(BootStrapClassLoader):用来加载java核心类库,无法被java程序直接引用;
  • 扩展类加载器(Extension ClassLoader):用来加载java的扩展库,java的虚拟机实现会提供一个扩展库目录,该类加载器在扩展库目录里面查找并加载java类;
  • 系统类加载器(AppClassLoader):它根据java的类路径来加载类,一般来说,java应用的类都是通过它来加载的;
  • 自定义类加载器:由java语言实现,继承自ClassLoader;

什么是双亲委派模型?为什么需要双亲委派模型?怎么打破双亲委派模型?

当一个类加载器收到一个类加载的请求,他首先不会尝试自己去加载,而是将这个请求委派给父类加载器去加载,只有父类加载器在自己的搜索范围类查找不到给类时,子加载器才会尝试自己去加载该类;

为了防止内存中出现多个相同的字节码;因为如果没有双亲委派的话,用户就可以自己定义一个java.lang.String类,那么就无法保证类的唯一性。

怎么打破双亲委派模型?

自定义加载器的话,需要继承 ClassLoader 。如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法

列举一些你知道的打破双亲委派机制的例子,为什么要打破?

Tomcat,应用的类加载器优先自行加载应用目录下的 class,并不是先委派给父加载器,加载不了才委派给父加载器。

tomcat之所以造了一堆自己的classloader,大致是出于下面三类目的:

  • 对于各个 webapp中的 class和 lib,需要相互隔离,不能出现一个应用中加载的类库会影响另一个应用的情况,而对于许多应用,需要有共享的lib以便不浪费资源。
  • 与 jvm一样的安全性问题。使用单独的 classloader去装载 tomcat自身的类库,以免其他恶意或无意的破坏;
  • 热部署。

调优

说一下 JVM 调优的命令?

  • jps:JVM Process Status Tool,显示指定系统内所有的HotSpot虚拟机进程。
  • jstat:jstat(JVM statistics Monitoring)是用于监视虚拟机运行时状态信息的命令,它可以显示出虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据。
  • jmap:jmap(JVM Memory Map)命令用于生成heap dump文件,如果不使用这个命令,还阔以使用-XX:+HeapDumpOnOutOfMemoryError参数来让虚拟机出现OOM的时候·自动生成dump文件。jmap不仅能生成dump文件,还阔以查询finalize执行队列、Java堆和永久代的详细信息,如当前使用率、当前使用的是哪种收集器等。
  • jhat:jhat(JVM Heap Analysis Tool)命令是与jmap搭配使用,用来分析jmap生成的dump,jhat内置了一个微型的HTTP/HTML服务器,生成dump的分析结果后,可以在浏览器中查看。在此要注意,一般不会直接在服务器上进行分析,因为jhat是一个耗时并且耗费硬件资源的过程,一般把服务器生成的dump文件复制到本地或其他机器上进行分析。
  • jstack:jstack用于生成java虚拟机当前时刻的线程快照。jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做什么事情,或者等待什么资源。如果java程序崩溃生成core文件,jstack工具可以用来获得core文件的java stack和native stack的信息,从而可以轻松地知道java程序是如何崩溃和在程序何处发生问题。

说一下可视化故障处理工具?

jconsole:用于对 JVM 中的内存、线程和类等进行监控;
visual vm:JDK 自带的全能分析工具,可以分析:内存快照、线程快照、程序死锁、监控内存的变化、gc 变化等。

JVM参数

  • 堆内存最小和最大:–Xms和-Xmx
  • 新生代最小与最大:-XX:NewSize和-XX:MaxNewSize
  • 新生代固定大小:-Xmn
  • 新生代和老年代比值:-XX:NewRatio=

频繁FULL GC如何调优

将新对象预留在新生代,由于 Full GC 的成本远高于 Minor GC,因此尽可能将对象分配在新生代是明智的做法,实际项目中根据 GC 日志分析新生代空间大小分配是否合理,适当通过“-Xmn”命令调节新生代大小,最大限度降低新对象直接进入老年代的情况。

场景题

top 命令

top命令使我们最常用的 Linux 命令之一,它可以实时的显示当前正在执行的进程的 CPU 使用率,内存使用率等系统信息。top -Hp pid 可以查看线程的系统资源使用情况。

vmstat 命令

vmstat 是一个指定周期和采集次数的虚拟内存检测工具,可以统计内存,CPU,swap 的使用情况,它还有一个重要的常用功能,用来观察进程的上下文切换。

pidstat 命令

pidstat 是 Sysstat 中的一个组件,也是一款功能强大的性能监测工具,top 和 vmstat 两个命令都是监测进程的内存、CPU 以及 I/O 使用情况,而 pidstat 命令可以检测到线程级别的。

jstack 命令

jstack 是 JDK 工具命令,它是一种线程堆栈分析工具,最常用的功能就是使用 jstack pid 命令查看线程的堆栈信息,也经常用来排除死锁情况。

jstat 命令

它可以检测 Java 程序运行的实时情况,包括堆内存信息和垃圾回收信息,我们常常用来查看程序垃圾回收情况。常用的命令是jstat -gc pid。

jmap 命令

jmap 也是 JDK 工具命令,他可以查看堆内存的初始化信息以及堆内存的使用情况,还可以生成 dump 文件来进行详细分析。查看堆内存情况命令jmap -heap pid。

mat 内存工具

MAT(Memory Analyzer Tool)工具是 eclipse 的一个插件(MAT 也可以单独使用),它分析大内存的 dump 文件时,可以非常直观的看到各个对象在堆空间中所占用的内存大小、类实例数量、对象引用关系、利用 OQL 对象查询,以及可以很方便的找出对象 GC Roots 的相关信息。

idea 中也有这么一个插件,就是 JProfiler。

如何定位CPU占满

请求接口地址测试curl localhost:8080/cpu/loop,发现 CPU 立马飙升到 100%

  • 通过执行top -Hp 32805 查看 Java 线程情况

  • 执行 printf '%x' 32826 获取 16 进制的线程 id,用于dump信息查询,结果为 803a。最后我们执行jstack 32805 |grep -A 20 803a来查看下详细的dump信息。

这里dump信息直接定位出了问题方法以及代码行,这就定位出了 CPU 占满的问题。

内存泄露

使用ThreadLocal模拟内存泄露

我们给启动加上堆内存大小限制,同时设置内存溢出的时候输出堆栈快照并输出日志。

java -jar -Xms500m -Xmx500m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof -XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:/tmp/heaplog.log analysis-demo-0.0.1-SNAPSHOT.jar

启动成功后我们循环执行 100 次,查看系统日志出现了如下异常:

java.lang.OutOfMemoryError: Java heap space

我们用jstat -gc pid 命令来看看程序的 GC 情况。发现进行了45 次 Full Gc 之后都没释放出可用内存,这说明当前堆内存中的对象都是存活的,有 GC Roots 引用,无法回收

我们之前保存了堆 Dump 文件,这个时候借助我们的 MAT 工具来分析下。

这里已经列出了可疑的 4 个内存泄漏问题,我们点击其中一个查看详情。

这里已经指出了内存被线程占用了接近 50M 的内存,占用的对象就是 ThreadLocal。如果想详细的通过手动去分析的话,可以点击Histogram,查看最大的对象占用是谁,然后再分析它的引用关系,即可确定是谁导致的内存溢出。

上图发现占用内存最大的对象是一个 Byte 数组,我们看看它到底被那个 GC Root 引用导致没有被回收。按照上图红框操作指引,结果如下图:

我们发现 Byte 数组是被线程对象引用的,图中也标明,Byte 数组对象的 GC Root 是线程,所以它是不会被回收的,展开详细信息查看,我们发现最终的内存占用对象是被 ThreadLocal 对象占据了。这也和 MAT 工具自动帮我们分析的结果一致。

死锁

死锁会导致耗尽线程资源,占用内存,表现就是内存占用升高,CPU 不一定会飙升(看场景决定),如果是直接 new 线程,会导致 JVM 内存被耗尽,报无法创建线程的错误,这也是体现了使用线程池的好处。

通过ps -ef|grep java命令找出 Java 进程 pid,执行jstack pid 即可出现 java 线程堆栈信息,这里发现了 5 个死锁,我们只列出其中一个,很明显线程pool-1-thread-2锁住了0x00000000f8387d88等待0x00000000f8387d98锁,线程pool-1-thread-1锁住了0x00000000f8387d98等待锁0x00000000f8387d88,这就产生了死锁。

线程频繁切换

上下文切换会导致将大量 CPU 时间浪费在寄存器、内核栈以及虚拟内存的保存和恢复上,导致系统整体性能下降。当你发现系统的性能出现明显的下降时候,需要考虑是否发生了大量的线程上下文切换。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值