JAVA基础—JVM

  • jvm是可运行java代码的假象计算机, 包括一套字节码指令集、一组寄存器、一个栈、一个垃圾回收、堆和一个存储方法域。jvm是运行在操作系统之上的,它与硬件没有直接的交互。
  • 一个java程序开始运行,虚拟机已经实例化了,多个程序有多个jvm实例,多个实例之间数据不共享。程序推出或关闭,则虚拟机实例消亡。

    线程

  • hotspot jvm种的java线程与操作系统原生线程有直接的映射关系。当本地线程存储、缓冲区分配、同步对象、栈、程序计数器等准备好以后,就会创建一个操作系统原生线程。java线程结束,原生线程随之被回收。操作系统负责调度所有线程,并发他们分配到可用到cpu上。当原生线程初始化完毕,就会调研java线程到run方法。当线程结束时,会释放原生线程和java线程的所有资源。

image.png

Java内存区域(运行时数据区域)和内存模型(JMM)

  • Java 内存区域和内存模型是不一样的东西,内存区域是指 Jvm 运行时将数据分区域存储,强调对内存空间的划分。
  • 而内存模型(Java Memory Model,简称 JMM )是定义了线程和主内存之间的抽象关系,即 JMM 定义了 JVM 在计算机内存(RAM)中的工作方式,如果我们要想深入了解Java并发编程,就要先理解好Java内存模型。

    Java运行时数据区域
  • 众所周知,Java 虚拟机有自动内存管理机制,如果出现内存泄漏和溢出方面的问题,排查错误就必须要了解虚拟机是怎样使用内存的。

  • JDK8之后的JVM内存布局

JAVA基础--JVM - 图2

  • JDK8之前的内存区域图

JAVA基础--JVM - 图3
JAVA基础--JVM - 图4

在 HotSpot JVM 中,永久代中用于存放类和方法的元数据以及常量池,比如Class和Method。每当一个类初次被加载的时候,它的元数据都会放到永久代中。 永久代是有大小限制的,因此如果加载的类太多,很有可能导致永久代内存溢出,即万恶的 java.lang.OutOfMemoryError: PermGen ,为此我们不得不对虚拟机做调优。 那么,Java 8 中 PermGen 为什么被移出 HotSpot JVM 了?我总结了两个主要原因:

  1. 由于 PermGen 内存经常会溢出,引发恼人的 java.lang.OutOfMemoryError: PermGen,因此 JVM 的开发者希望这一块内存可以更灵活地被管理,不要再经常出现这样的 OOM
  2. 移除 PermGen 可以促进 HotSpot JVM 与 JRockit VM 的融合,因为 JRockit 没有永久代。
    根据上面的各种原因,PermGen 最终被移除,方法区移至 Metaspace,字符串常量移至 Java Heap。

    引用自https://www.sczyh30.com/posts/Java/jvm-metaspace/

程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器内核都只会执行一条线程中的指令。
因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

虚拟机栈

与程序计数器一样,Java 虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。描述的是java方法执行的内存模型,每次方法调用的数据都是通过栈传递的。
java内存可粗略分为堆和栈,实际上虚拟机栈是一个个栈帧组成,每个栈帧有:局部变量表、操作数栈、动态连接、方法出口信息。
虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame,是方法运行时的基础数据结构)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
在活动线程中,只有位千栈顶的帧才是有效的,称为当前栈帧。正在执行的方法称为当前方法,栈帧是方法运行的基本结构。在执行引擎运行时,所有指令都只能针对当前栈帧进行操作。
JAVA基础--JVM - 图5

堆内存
  • 虚拟机管理内存最大的一块,唯一目的就是存放对象实例,几乎所有的对象实例数组都在这分配内存。
  • 是垃圾收集器管理的主要区域,
  • 7之前:新生代(eden,from surivivor,to surivivor),老生代,永生代
  • 8之后:一处永久代,元空间代替,元空间使用直接内存。

    方法区
  • 存储已被虚拟机加载的类信息、常量、静态变量、即使编译器编译后的代码等

  • 堆的一种逻辑部分
  • 也被称为永久代

    运行时常量池
  • 方法区的一部分,Class文件处理类的版本、字段、方法、接口等描述信息外,还有常量池表(存放编译期生成的各种字面量和符号引用)。

  • 7前运行时常量池逻辑包含字符串常量池存放在方法区,此时hotspot虚拟机对方法区的实现为永久代
  • 7时字符串常量池被从方法区拿到了堆中,这里没有提到有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区,也就是hotspot中的永久代。
  • 8时hotspot移除了永久代,用用空间(matespace)取代,这时字符串常量池还在堆,运行时常量池还在方法区,不过方法区的实现从永久代变成了元空间。

  • 如何判断对象失效?
    • 引用计数法
      • 加引用计数器,有引用加一,引用失效减一,为0就不能使用。
    • 可达性分析法
  • 判断常量时废弃常量
    • 运行时常量池中主要回收的时废弃的常量。
    • 若字符串没有任何对象引用,就说明时废弃常量,此时若发送内存回收且有必要就会被系统清理出常量池。
  • 判断无用的类(可回收,而不是必须)
    • 所有实例都被回收,也就是堆中不存在任何该类的实例。
    • 加载该类的ClassLoader已经被回收
    • 对应的java.lang.Class对象没有被人格地方引用,无法在任何地方通过反射访问该类的方法。
  • 垃圾回收算法
    • 标记清除
      • 最基础的算法,先标记不需要回收的对象,回收没被标记的,会产生不连续的碎片。
    • 复制算法
      • 内存分为两块,先用一块,将存活的复制到另一块,再把使用的空间清理,这样每次回收都是对一半回收。
    • 标记整理
      • 根据老年代特点提出,于标记清除类似,单后续不是直接回收,而是先让存活的对象向一端移动,然后清理掉边界意外的内存。
    • 分代收集
      • 当前思想,堆分新生代、老年代根据各自特点选择合适的算法。
  • 判断对象存活(垃圾回收机制)

    • 可达性分析法

      这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。

    • 三色标记

  • 垃圾回收器
    • Seria收集器
    • ParNew收集器
    • Parallel Scavenge收集器
    • CMS收集器
    • G1收集器
      直接内存
      直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。
      在 JDK 1.4 中新加入了 NIO,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。
      显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括 RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx 等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。
      JAVA基础--JVM - 图6
      Java内存模型
      Java内存模型是共享内存的并发模型,线程之间主要通过读-写共享变量(堆内存中的实例域,静态域和数组元素)来完成隐式通信。
      Java 内存模型(JMM)控制 Java 线程之间的通信,决定一个线程对共享变量的写入何时对另一个线程可见。
      计算机高速缓存和缓存一致性
      计算机在高速的 CPU 和相对低速的存储设备之间使用高速缓存,作为内存和处理器之间的缓冲。将运算需要使用到的数据复制到缓存中,让运算能快速运行,当运算结束后再从缓存同步回内存之中。
      在多处理器的系统中(或者单处理器多核的系统),每个处理器内核都有自己的高速缓存,它们有共享同一主内存(Main Memory)。
      当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。
      为此,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,来维护缓存的一致性。
      JAVA基础--JVM - 图7
      JVM主内存与工作内存
      Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量(线程共享的变量)存储到内存和从内存中取出变量这样底层细节。
      Java内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。
      这里的工作内存是 JMM 的一个抽象概念,也叫本地内存,其存储了该线程以读 / 写共享变量的副本。
      就像每个处理器内核拥有私有的高速缓存,JMM 中每个线程拥有私有的本地内存。
      不同线程之间无法直接访问对方工作内存中的变量,线程间的通信一般有两种方式进行,一是通过消息传递,二是共享内存。Java 线程间的通信采用的是共享内存方式,线程、主内存和工作内存的交互关系如下图所示:
      JAVA基础--JVM - 图8
      这里所讲的主内存、工作内存与 Java 内存区域中的 Java 堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。
      重排序和happens-before规则
      在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:
  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
    从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

JAVA基础--JVM - 图9
JMM 属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
java 编译器禁止处理器重排序是通过在生成指令序列的适当位置会插入内存屏障(重排序时不能把后面的指令重排序到内存屏障之前的位置)指令来实现的。
happens-before
从 JDK5 开始,java 内存模型提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。
如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。
这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
如果 A happens-before B,那么 Java 内存模型将向程序员保证—— A 操作的结果将对 B 可见,且 A 的执行顺序排在 B 之前。重要的 happens-before 规则如下:

  1. 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
  2. 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
  3. volatile 变量规则:对一个 volatile 域的写,happens- before 于任意后续对这个 volatile 域的读。
  4. 传递性:如果 A happens- before B,且 B happens- before C,那么 A happens- before C。
    下图是 happens-before 与 JMM 的关系

JAVA基础--JVM - 图10

volatile关键字volatile
  • 可以说是 JVM 提供的最轻量级的同步机制,当一个变量定义为volatile之后,它将具备两种特性:
  1. 保证此变量对所有线程的可见性。而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成。
  • 注意,volatile 虽然保证了可见性,但是 Java 里面的运算并非原子操作,导致 volatile 变量的运算在并发下一样是不安全的。而 synchronized 关键字则是由“一个变量在同一个时刻只允许一条线程对其进行 lock 操作”这条规则获得线程安全的。
  1. 禁止指令重排序优化。普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。

  • 常见的垃圾回收器
  • JVM如何判断对象是无用对象
    • 跟搜索算法,从GC Root出发,对象没有引用,就判定为无用对象。
  • 跟搜索算法中的根节点可是哪些对象?
    • 类对象、虚拟机栈对象

参考


类加载机制

  • 加载-链接-初始化
    • 链接:验证-准备-解析
  • 双亲委派机制

    • 父类加载,不重复加载

      垃圾回收

      回收算法

  • 标记清楚

  • 标记复制
  • 引用计数
  • 分代回收

    回收器

    image.png

  • CMS

  • G1
  • full gc 老年代满,持久代不足,系统gc
  • Serial 垃圾收集器(单线程、复制算法)

Serial(英文连续)是最基本垃圾收集器,使用复制算法,曾经是JDK1.3.1 之前新生代唯一的垃圾收集器。Serial 是一个单线程的收集器,它不但只会使用一个 CPU 或一条线程去完成垃圾收集工作,并且在进行垃圾收集的同时,必须暂停其他所有的工作线程,直到垃圾收集结束。
Serial 垃圾收集器虽然在收集垃圾过程中需要暂停所有其他的工作线程,但是它简单高效,对于限定单个 CPU 环境来说,没有线程交互的开销,可以获得最高的单线程垃圾收集效率,因此 Serial垃圾收集器依然是 java 虚拟机运行在 Client 模式下默认的新生代垃圾收集器。
ParNew 垃圾收集器(Serial+多线程)
ParNew 垃圾收集器其实是 Serial 收集器的多线程版本,也使用复制算法,除了使用多线程进行垃圾收集之外,其余的行为和 Serial 收集器完全一样,ParNew 垃圾收集器在垃圾收集过程中同样也要暂停所有其他的工作线程。
ParNew 收集器默认开启和 CPU 数目相同的线程数,可以通过-XX:ParallelGCThreads 参数来限制垃圾收集器的线程数。【Parallel:平行的】
ParNew虽然是除了多线程外和Serial 收集器几乎完全一样,但是ParNew垃圾收集器是很多 java虚拟机运行在 Server 模式下新生代的默认垃圾收集器。
Parallel Scavenge 收集器(多线程复制算法、高效)
Parallel Scavenge 收集器也是一个新生代垃圾收集器,同样使用复制算法,也是一个多线程的垃圾收集器,它重点关注的是程序达到一个可控制的吞吐量(Thoughput,CPU 用于运行用户代码的时间/CPU 总消耗时间,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)),高吞吐量可以最高效率地利用 CPU 时间,尽快地完成程序的运算任务,主要适用于在后台运算而不需要太多交互的任务。自适应调节策略也是 ParallelScavenge 收集器与 ParNew 收集器的一个重要区别。
Serial Old 收集器(单线程标记整理算法 )
Serial Old 是 Serial 垃圾收集器年老代版本,它同样是个单线程的收集器,使用标记-整理算法,这个收集器也主要是运行在 Client 默认的 java 虚拟机默认的年老代垃圾收集器。
在 Server 模式下,主要有两个用途:
1. 在 JDK1.5 之前版本中与新生代的 Parallel Scavenge 收集器搭配使用。
2. 作为年老代中使用 CMS 收集器的后备垃圾收集方案。

Parallel Old 收集器(多线程标记整理算法)
Parallel Old 收集器是Parallel Scavenge的年老代版本,使用多线程的标记-整理算法,在 JDK1.6才开始提供。
在 JDK1.6 之前,新生代使用 ParallelScavenge 收集器只能搭配年老代的 Serial Old 收集器,只能保证新生代的吞吐量优先,无法保证整体的吞吐量,Parallel Old 正是为了在年老代同样提供吞吐量优先的垃圾收集器,如果系统对吞吐量要求比较高,可以优先考虑新生代 Parallel Scavenge和年老代 Parallel Old 收集器的搭配策略。

CMS 收集器(多线程标记清除算法)
Concurrent mark sweep(CMS)收集器是一种年老代垃圾收集器,其最主要目标是获取最短垃圾回收停顿时间,和其他年老代使用标记-整理算法不同,它使用多线程的标记-清除算法。
最短的垃圾收集停顿时间可以为交互比较高的程序提高用户体验。
CMS 工作机制相比其他的垃圾收集器来说更复杂,整个过程分为以下 4 个阶段:
初始标记
只是标记一下 GC Roots 能直接关联的对象,速度很快,仍然需要暂停所有的工作线程。
并发标记
进行 GC Roots 跟踪的过程,和用户线程一起工作,不需要暂停工作线程。
重新标记
为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记
记录,仍然需要暂停所有的工作线程。
并发清除
清除 GC Roots 不可达对象,和用户线程一起工作,不需要暂停工作线程。由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作,所以总体上来看CMS 收集器的内存回收和用户线程是一起并发地执行。
image.png

G1 收集器
Garbage first 垃圾收集器是目前垃圾收集器理论发展的最前沿成果,相比与 CMS 收集器,G1 收集器两个最突出的改进是:
1. 基于标记-整理算法,不产生内存碎片。
2. 可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。
G1 收集器避免全区域垃圾收集,它把堆内存划分为大小固定的几个独立区域,并且跟踪这些区域的垃圾收集进度,同时在后台维护一个优先级列表,每次根据所允许的收集时间,优先回收垃圾最多的区域。区域划分和优先级区域回收机制,确保 G1 收集器可以在有限时间获得最高的垃圾收集效率。

调优

  • 设置堆大小 -smx -xmx
  • 调整老年代年轻代比例,原则减少gc

1)-XX:NewSize和-XX:MaxNewSize
用于设置年轻代的大小,建议设为整个堆大小的1/3或者1/4,两个值设为一样大。
2)-XX:SurvivorRatio
用于设置Eden和其中一个Survivor的比值,这个值也比较重要。
3)-XX:+PrintTenuringDistribution
这个参数用于显示每次Minor GC时Survivor区中各个年龄段的对象的大小。
4).-XX:InitialTenuringThreshol和-XX:MaxTenuringThreshold
用于设置晋升到老年代的对象年龄的最小值和最大值,每个对象在坚持过一次Minor GC之后,年龄就加1。

排查

  • full gc 排查
    • jasvism
    • dump
    • 监控配置自动dump

内存泄露与内存溢出

  • 内存泄漏
    • 申请的内存空间没有被正确释放,导致后续程序里这块内存被永远占用(不可达),而且指向这块内存的指针不再存在时,这块内存也就永远不可达了,内存空间就一点点被蚕食。
    • java里不存在,gc采用跟搜索算法时,不可达的对象会被回收,gc
  • 内存溢出
    • 指存储的内存数据超出了指定空间的大小,这时数据就会越界,举例来说,常见的溢出在栈空间里,分配了超过数组长度的数据,导致多出来的数据覆盖了栈空间其他位置的数据。一般指OOM