JVM

本系列参考自Java面试小抄以及黑马程序员和书籍《深入理解Java虚拟机:JVM高级特性与最佳实践(第二版)》

JVM内存结构

JVMrundataarea.png

  • 程序计数器:为了线程切换后能恢复到正确的执行位置,所以每条线程都需要一个独立的程序计数器。如果线程正在执行一个Java方法,该计数器记录的是正在执行的虚拟机字节码指令的地址,如果正在执行的是native method,这个计数器值为空。此内存空间是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError的区域
  • 虚拟机栈:线程每个方法在执行的同时会创建一个栈帧,用于存储局部变量、操作数栈、动态链接、方法出口等信息。如果线程请求的栈深大于虚拟机所允许的深度,抛出StackOverError。如果虚拟机栈可以动态扩展,扩展时无法申请到足够内存,抛出OutOfMemoryError。
  • 本地方法栈:与虚拟机栈类似,但保存的是native method的信息,当一个jvm创建的线程调用native方法后,jvm不会在虚拟机栈中为该线程创建栈帧,而是简单的动态链接并直接调用该方法;
  • 堆:所有线程共享的一块内存,大多数的对象实例以及数组都要在堆上分配。
  • 方法区:用于存储已被虚拟机加载的类信息、常量、静态变量,即时编译器编译后的代码等数据。
  • 运行时常量池:方法区的一部分。Class文件中常量池用于存放编译期生成的各种字面量和符号引用,在类加载后进入方法区的运行时常量池中存放。[[Java基础知识#常量池|相关]]

heap和stack的区别:

stack由系统自动分配。heap需要程序员自己申请并指明大小,通过new分配。
stack只要剩余空间大于所申请空间,系统将为程序提供内存,否则抛出栈溢出。heap遍历系统记录空闲内存地址的链表,寻找第一个空间大于所申请空间的堆结点,将其从链表删除并将该结点空间分配给程序。
stack是一块连续的内存区域,向低地址扩展。heap是不连续的内存区域,向高地址扩展。
stack在函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,即主函数的吓一跳指令,程序从该点继续运行。heap是在堆的头部用一个字节存放堆的大小。

JMM

Java Memory Model,Java内存模型
jmm.png
其中,主内存存储所有的变量,每条线程还有会自己的工作内存,保存了被该线程使用到的主内存部分拷贝,线程对变量的所有操作都必须在工作内存中进行。
volatile关键字:保证了多线程操作时变量的可见性和有序性。volatile的特殊规则保证了新值能立即同步到主内存,每次使用前立即从主内存刷新。
synchronized关键字:保证可见性和有序性,通过对变量上锁实现,决定了持有同一个锁的两个同步块只能串行进入。

可见性:当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。JMM通过在工作内存中修改变量后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性。
有序性:如果在本线程内观察,所有的操作都是有序的,如果在另一个线程内观察,所有操作都是无序的。前半句指线程内表示穿行,后半句指指令重排序现象和工作内存与主内存同步延迟现象。

先行先发生happens-before原则:如果A操作先行发生于B操作,意为A操作产生的影响能使B操作发生操作前观察到。

//线程A执行
i=1;
//线程B执行
j=i;
//线程C执行
i=2;

假设A先行先发生于B,j的值可能为1或2,因为在AB执行过程中,C操作有可能被B观察到,也有可能未被观察到。
可以在编码中直接使用的先行发生关系:

  • 程序次序规则:按照程序代码顺序,书写在前面的操作先行发生于后面操作。
  • 管程锁定规则:一个unclock操作先行发生于同一个锁的lock操作
  • volatile规则:对一个volatile变量,写操作先行发生于后面对变量的读操作
  • 线程启动规则:Thread对象的start()方法先行于每一个动作。
  • 线程终止规则:线程所有动作都先行于对此线程的终止检测,join()方法结束。
  • 线程中断规则
  • 对象终结规则
  • 传递性

垃圾收集Garbage Collection GC

对象回收

即不可能再被任何途径使用的对象。

  1. 引用计数法
    为每个对象设置一个引用计数器。当有一个地方引用它,计数器值+1,引用失效,计数器值-1。当计数器值=0时,说明它不可能再被使用。
    使用简单,但很难解决对象之间相互循环引用的问题,即两个需要回收的对象相互引用彼此,则无法识别出是否需要回收。
  2. 可达性分析法
    通过一系列称为“GC Roots”的对象作为起始点向下搜索。如果一个对象到GC Roots没有任何引用链相连接时,说明此对象不可用。
    但一个对象满足上述条件的时候,不会马上被回收,还需要进行两次标记;第一次标记:判断当前对象是否有必要执行finalize()方法,当对象没有覆盖方法或者该方法已被虚拟机调用过,则标记为垃圾对象,等待回收;若有的话,则进行第二次标记;第二次标记将当前对象放入F-Queue队列,并生成一个finalize线程去执行该方法,虚拟机不保证该方法一定会被执行,这是因为如果线程执行缓慢或进入了死锁,会导致回收系统的崩溃;如果执行了finalize方法之后仍然没有与GC Roots有直接或者间接的引用,则该对象会被回收;

引用

强引用:类似Object obj=new Object(),只要强引用存在,GC永远不会回收掉被引用对象
软引用:用于维护一些可有可无的对象。只有在内存不足时,系统则会回收软引用对象,如果回收了软引用对象之后仍然没有足够的内存,才会抛出内存溢出异常。提供SoftReference类实现软引用
弱引用:相比软引用来说,要更加无用一些,它拥有更短的生命周期,当 JVM 进行GC时,无论内存是否充足,都会回收被弱引用关联的对象。提供WeakReference类实现弱引用
虚引用:是一种形同虚设的引用,在现实场景中用的不是很多,它主要用来跟踪对象被垃圾回收的活动。提供PhantomReference类实现虚引用。

垃圾回收算法

  • 标记-清除算法
    首先利用可达性遍历内存,标记出所有需要回收的对象,在标记完成后统一回收。
    特点:效率不高且标记清除后会产生大量不连续的内存碎片。
  • 标记整理法
    第一步:利用可达性去遍历内存,把存活对象和垃圾对象进行标记; 第二步:将所有的存活的对象向一段移动,将端边界以外的对象都回收掉;
    特点:适用于存活对象多,垃圾少的情况;需要整理的过程,无空间碎片产生;
  • 复制算法
    将内存按照容量划分为大小相等的两块,每次只使用其中一块,当一块使用完就将还存活的对象移植到另一块,然后将这块内存空间移除
    特点:内存使用率极低
  • 分代收集算法
    根据内存对象的存活周期不同,将内存划分成几块,虚拟机一般将内存分成新生代老生代,在新生代中,有大量对象死去和少量对象存活,所以采用复制算法,只需要付出少量存活对象的复制成本就可以完成收集;老年代中因为对象的存活率极高,没有额外的空间对他进行分配担保,所以采用标记清理或者标记整理算法进行回收;
    Pasted image 20230529111710.png

垃圾回收器

Serial:新生代单线程收集器。进行垃圾收集时,必须暂停其他所有的工作线程STW(Stop-the-World),但是它简单高效,是Client模式下的默认GC方法。这个因内存回收而导致停顿的努力一直在进行,但只能尽可能让停顿时间不断缩短,仍没办法完全消除。
ParNew:是Serial的多线程版本,是运行在Server模式下的首选新生代GC。
Parallel Scavenge:新生代并行多线程收集器。目标是达到一个可控的吞吐量
以及与之对应的老年代GC:Serial Old、Parallel Old都是使用的标记整理算法。
CMS:Concurrent Mark Sweep,标记清除算法。以获取最短回收停顿时间为目标的收集器。应用于B/S系统的服务端。重视服务的响应速度,希望系统停顿时间最短,给用户带来较好的体验。
整个过程分为四个步骤:初始标记->并发标记->重新标记->并发清除。其中初始标记和重新标记需要STW,但是时间很短,而其他步骤则可以并发进行。
缺点:

  • 并发回收时垃圾收集线程占用CPU资源较多。
  • 可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生,反而停顿时间更久。
  • 在并发标记和并发清理阶段,用户线程还在继续运行,就还会伴随有新的垃圾对象不断产生,但这一部分垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们,只好留到下一次垃圾收集时再清理掉。
  • 结束时会产生大量空间碎片。
    G1:Garbage-First,标记整理算法。
    运作流程主要包括以下:初始标记STW->并发标记->最终标记STW->筛选回收STW。初始标记仅仅标记以下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值。并发标记时从GC Roots开始对堆中对象进行可达性分析,找出存活对象。最终标记时为了修正在并发标记期间因用户程序继续运作而导致标记产生变得的部分,虚拟机将其变化记录在线程Remembered Set Logs中,所以需要将Remembered Set Logs的数据合并到Remembered Set中。筛选阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来指定回收计划。
    特点:不会产生空间碎片,可以精确地控制停顿;G1将整个堆分为大小相等的多个Region(区域),G1跟踪每个区域的垃圾大小,在后台维护一个优先级列表,每次根据允许的收集时间,优先回收价值最大的区域,已达到在有限时间内获取尽可能高的回收效率;

Minor GC:指发生在新生代的垃圾收集动作
Major/Full GC:发生在老年代的GC。

类加载

Pasted image 20230605111207.png
加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)
类加载过程如下:

  • 加载,加载分为三步: 1、通过类的全限定性类名获取该类的二进制流; 2、将该二进制流的静态存储结构转为方法区的运行时数据结构; 3、在内存中生成一个代表该类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口;
  • 验证:验证该class文件中的字节流信息符合虚拟机的要求,不会威胁到jvm的安全;
  • 准备:为class对象的静态变量分配内存,初始化其初始值;
  • 解析:该阶段主要完成符号引用转化成直接引用;
  • 初始化:到了初始化阶段,才开始执行类中定义的java代码;初始化阶段是调用类构造器的过程;

双亲委派模型

Pasted image 20230605114203.png

  • 启动类加载器:负责将存放在<JAVA_HOME>/lib目录或被-Xbootclasspath参数指定的路径中的可被虚拟机识别的类库加载到虚拟机内存中。
  • 扩展类加载器:负责加载<JAVA_HOME>/lib/ext或被java.ext.dirs系统变量指定的路径的所有类库
  • 应用程序类加载器:加载用户类路径上所指定的类库。
    双亲委派模型:如果一个类加载器收到了类加载的请求,首先将这个请求委派给父类加载器完成。因此所有的加载请求最终都应传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成请求时,子加载器才会尝试自己加载。

对象创建过程

  1. 检查类是否已经被加载:JVM遇到一条新建对象的指令时,首先检查该指令参数能否在常量池中定义到一个类的符号引用,然后加载这个类。
  2. 为对象分配内存:加载完类后,在堆内存中为该对象分配一定的空间。
  3. 为对象字段设置零值:对象头除外,堆对象的字段赋0值或null值。
  4. 设置对象头:虚拟机堆创建的对象进行信息标记,包括是否为新老生代、对象哈希码、元数据信息等。
  5. 执行对象构造函数。

为对象分配空间有两种方式:
a)指针碰撞。JVM将堆抽象为两块区域,一块是被其他对象占用的区域,另一块是空白区域,中间通过一个指针进行标注。只需要将指针向空白区域移动相应大小空间即可。这种方式在多线程创建对象时,会导致指针划分不一致的问题,指针是一个临界资源。
b)虚拟机为每个线程分配不同的空间,当线程自己的空间用完了才需要申请空间,需要进行同步锁定。为每个线程分配的空间称为“本地线程分配缓冲”(TLAB)。

posted @ 2023-06-08 14:05  梅落南山  阅读(22)  评论(0编辑  收藏  举报