JVM与GC

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档

前言

不足和错误的地方欢迎指正

说下JVM的主要组成部分和作用

  1. 子系统Class Louder,类加载器

    比如把java.lang.object加载到运行时数据区的方法区

  2. 组件Running data area,运行时方法(数据)区,也叫JVM内存

    类和对象加载到这里

  3. 子系统Excutioon engine,执行引擎

    将编译后的字节码翻译成底层系统指令,再让CPU去执行

  4. 组件Native interface,本地接口

    执行引擎翻译字节码需要调用其他语言的本地库接口,这个组件可以与Native libraries交互,实现与其他语言的交互,满足执行引擎的需求

谈谈你对运行时数据区的理解

  1. 程序计数器(Program Counter Register):私有线程,程序空间很小

    正在执行的线程的字节码的行号指示器,记录JVM正在执行的线程指令地址。字节码解析器(应该是虚拟机栈里的东西)的工作是通过改变这个计数器的值,来选取下一条需要执行的字节码指令、分支、循环、跳转、异常处理、线程恢复 等基础功能。

  2. Java虚拟机栈(Java Virtual Machine Stacks):私有线程,虚拟机用来解释Class字节码

    虚拟机栈是服务java方法的,存储Java方法。每个方法 执行的时候都会创建一个栈帧,用于存储方法的信息(局部变量表、操作数栈、动态链接和方法返回 等)。 线程请求的栈深超过了虚拟机允许的最大,会抛出StackOverFlowError 异常

  3. 本地方法栈(Native Method Stack):私有线程,存的是C和C++函数

    与虚拟机栈的作用是一样的,区别在于 虚拟机栈是服务java方法的,本地方法栈是为JVM通过动态链接 直接调用本地方法服的。存储本地方法(可以理解为所有非Java方法)

  4. 方法区(Method Area):用于存储每一个类的结构信息,例如,运行时常量池(runtime constant pool)、字段和方法数据、构造函数和普通方法的字节码内容,还包括一些在类、实例、接口初始化时用到的特殊方法;

    jdk8以后取消永久代,方法区里的 运行时常量池 被放到heap,其他的数据存在元空间,元空间不在虚拟机中而是使用本地内存,因此默认情况下元空间的大小只受本地内存的限制。

    • 加载的类信息,归于元空间
    • 运行时的常量池、字符串常量池被放到Heap中
  5. Java堆(Java Heap):占用JVM内存大,所有线程共享

    几乎所有的对象和数组都要在堆上分配内存,垃圾回收也在这里发生

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bLQ9GqKw-1682231171117)(C:\Users\Joshua.TV\Pictures\webPhoto_Typora\watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dlaXhpbl80NDU1Njk2OA==,size_16,color_FFFFFF,t_70#pic_center.png)]

  • .java源码被编译成.class字节码后,会被加载到Method Area(这里会存储所有的类信息)。程序运行中创建的对象、数组、常量池会被放在heap里面
  • JVMS服务于Java方法,NMS服务于虚拟机外的非Java方法。PCR用来给JVMS指示running线程指令地址的

栈内存的概述

全称Java虚拟机栈,是Java的一种内存。保存着一个线程中方法的调用状态。换句话说,是用来执行程序的,所以虚拟机栈是线程私有的,独有的,随着线程的创建而创建,每个方法的执行对应一个栈帧,例如调用一个方法,就会向栈中压入一个栈帧;一个方法调用完成,就会把该栈帧从栈中弹出。栈中主要存放一些基本类型的变量和对象引用。

  • 栈内存的释放

    当在一段代码块定义一个变量时,Java就在栈中为这个变量分配内存空间。该变量退出其的作用域后,Java会自动释放掉为该变量所分配的内存空间,该内存空间可以立即被另作他用。

堆和栈的区别

栈(stack)与堆(heap)都是Java用来在Ram(随机存储内存)中存放数据的地方。Java自动管理栈和堆,程序员不能直接地设置栈或堆。

栈内存(全称虚拟机栈)堆内存
存放基本类型的变量和对象的引用;栈数据可以共享;先进后出存放由new创建的对象和数组以及常量池
存取速度比堆要快,仅次于直接位于CPU中的寄存器由于要在运行时动态分配内存,存取速度较慢
数据大小与生存期必须是确定的,缺乏灵活性生存期不必事先告诉编译器;
超出作用域后自动释放为该变量分配的内存空间垃圾收集器会自动收走这些不再使用的数据

为什么要把堆和栈区分出来

  1. 从软件设计的角度看,栈代表了处理逻辑,而堆代表了数据,这样分开使得处理逻辑清晰,是分治的思想。这种隔离、模块化的思想在软件设计的方方面面都有体现。
  2. 这种分离使得堆中的内容可以被多个栈共享(多个线程访问同一个对象)

对象的访问定位的方式

Java对象的访问,需要通过 虚拟机栈.reference类型的数据区 操作具体的对象。但reference类型在JVM规范中只规定了一个对象的引用,没说这个引用应该用那种方式定位。所以应该具体情况具体分析

  • 句柄访问

    Java堆会划分内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含对象数据的地址和对象类型数据的具体地址信息。举例

    Object obj = new Object();
    //Object obj表示类型的引用变量,存储在栈内存的引用变量表中,表示一个reference类型的数据
    //new Object()作为对象放在堆内存中,同时堆内存还记录了Object类的信息(对象类型、实现接口、方法之类)的 地址。这些地址对应的数据类型存储在方法去(栈内存)
    
  • 指针访问

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

句柄访问的好处指针访问的好处
reference中存储这稳定的句柄地址,当对象移动之后(垃圾收集时移动对象的普遍行为),只需要改变句柄中的对象地址即可,reference不用修改访问速度快,减少了一次指针定位的时间开销,Java中对象的访问频繁,减少这类开销累计起来,提高了效率

判断垃圾可回收的方法

  • 引用计数法(判断对象的引用数量):堆中每个对象都有一个引用计数器,每当有一个地方引用它,计数器的值就加1,当引用失效时,计数器的值就减1。计数器值为0的对象可以被当作垃圾回收

    • 优点:实现简单,效率高
    • 缺点:无法检测出循环引用,比如父对象和子对象相互引用,导致他们的引用计数永远不为0,从而无法被回收
  • 可达性分析算法(判断对象的引用链是否可达):以GC Root为起点,开始往下搜索,所走过的路径称为引用链,当GC Root和一个对象之间没有任何引用链相连时,证明此对象不可用。可作为GC Root的对象包括:

    1. 虚拟机栈中的引用对象
    2. 方法区中类静态属性引用的对象
    3. 方法区中常量引用的对象
    4. 本地方法栈中引用的对象

    在可达性分析算法中不可达的对象,暂时处于“缓刑”阶段,真正宣告一个对象死亡,至少要经历两次标记:

    1. 对象在进行可达性分析后发现没有与GC Root相连接的引用链,会被第一次标记
    2. 对象在执行finalize方法时能否与引用链建立关联关系,如果没有建立,那么将会被第二次标记,否则对象将逃离筛选

谈谈对内存泄漏和内存溢出的理解

  • 内存溢出 out of memory,是指程序在申请内存时,没有足够的内存空间供其使用,出现out of memory;比如申请了一个integer,但给它存了long才能存下的数,那就是内存溢出。

  • 内存泄露 memory leak,是指程序在申请内存后,无法释放已申请的内存空间,一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。

    memory leak会最终会导致out of memory

内存泄漏的根本原因是什么

内存泄漏是指你向系统申请分配内存进行使用(new),可是使用完了以后却不归还(delete),结果你申请到的那块内存你自己也不能再访问(也许你把它的地址给弄丢了),而系统也不能再次将它分配给需要的程序。一个盘子用尽各种方法只能装4个果子,你装了5个,结果掉倒地上不能吃了。这就是溢出!比方说栈,栈满时再做进栈必定产生空间溢出,叫上溢,栈空时再做退栈也产生空间溢出,称为下溢。就是分配的内存不足以放下数据项序列,称为内存溢出.

可能发生内存泄漏的情况

  1. 常发性内存泄漏。发生内存泄漏的代码会被多次执行到,每次被执行的时候都会导致一块内存泄漏。
  2. 偶发性内存泄漏。发生内存泄漏的代码只有在某些特定环境或操作过程下才会发生。常发性和偶发性是相对的。对于特定的环境,偶发性的也许就变成了常发性的。所以测试环境和测试方法对检测内存泄漏至关重要。
  3. 一次性内存泄漏。发生内存泄漏的代码只会被执行一次,或者由于算法上的缺陷,导致总会有一块仅且一块内存发生泄漏。比如,在类的构造函数中分配内存,在析构函数中却没有释放该内存,所以内存泄漏只会发生一次。
  4. 隐式内存泄漏。程序在运行过程中不停的分配内存,但是直到结束的时候才释放内存。严格的说这里并没有发生内存泄漏,因为最终程序释放了所有申请的内存。但是对于一个服务器程序,需要运行几天,几周甚至几个月,不及时释放内存也可能导致最终耗尽系统的所有内存。所以,我们称这类内存泄漏为隐式内存泄漏。

一般情况下用户感受不到内存泄漏的存在,当内存泄漏堆积到发生内存溢出时,也意味着内存泄漏掉系统所有内存

为什么要移除永久代

字符串存在永久代中,容易出现性能问题和内存溢出。

类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。

永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。

谈谈对Java中引用的了解

  • 强引用: 指被强引用关联的对象不会被垃圾回收器回收。通过 new 一个新对象的方式来创建强引用。
  • 软引用: 被软引用关联的对象只有在内存不够时才会被回收。使用 SoftReference 类来创建软引用。用途: 浏览器的后退按钮,如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,又需要重新加载,所以这里可以采用软引用来将这些对象列入回收范围,只有当没有足够的内存时才对它进行回收。
  • 弱引用: 弱引用的强度比软引用更弱一些,被弱引用关联的对象一定会被回收,它只能存活到下一次垃圾回收发生之前。在 ThreadLocal 中有被使用。使用 WeakReference 类来创建弱引用。
  • 虚引用: 是最弱的一种引用。一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用获得一个对象。为对象设置虚引用的唯一目的是能在这个对象被收集器回收时收到一个系统通知。使用 PhantomReference 来创建虚引用。
  • (虚引用、软引用、弱引用的区别:虚引用必须和引用队列联合使用,垃圾回收器回收对象前,会把这个虚引用加入到与之关联的引用队列中,这样就可以通过判断引用队列是否加入了虚引用来提早了解被引用的对象是否将要被垃圾回收器回收了,而且虚引用一旦通知,对象就不可能再生,因为它的 finalize 方法已经执行一次了)

强引用、若引用、虚引用和幻象引用的区别

对于虚拟机来说,主要体现的是对象的不同的可达性(reachable) 状态和对垃圾收集(garbage collector)的影响。

可以通过下面的流程来对对象的生命周期做一个总结

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Qlsqtsbr-1682231171119)(C:\Users\Joshua.TV\Pictures\webPhoto_Typora\ccb7b668399fb5ffe88954e7067e62c7.jpeg)]

图中用红色标明的区域表示对象处于强可达阶段。

如果只讨论符合垃圾回收条件的对象,那么只有三种:软可达、弱可达和幻象可达。

  • 软可达:软可达就是我们只能通过软引用才能访问的状态,软可达的对象是由 SoftReference 引用的对象,并且没有强引用的对象。软引用是用来描述一些还有用但是非必须的对象。垃圾收集器会尽可能长时间的保留软引用的对象,但是会在发OutOfMemoryError 之前,回收软引用的对象。如果回收完软引用的对象,内存还是不够分配的话,就会直接抛出 OutOfMemoryError。
  • 弱可达:弱可达的对象是 WeakReference 引用的对象。垃圾收集器可以随时收集弱引用的对象,不会尝试保留软引用的对象。
  • 幻象可达:幻象可达是由 PhantomReference 引用的对象,幻象可达就是没有强、软、弱引用进行关联,并且已经被 finalize(回收标记) 过了,只有幻象引用指向这个对象的时候。

除此之外,还有强可达和不可达的两种可达性判断条件

  • 强可达:就是一个对象刚被创建、初始化、使用中的对象都是处于强可达的状态
  • 不可达(unreachable):处于不可达的对象就意味着对象可以被清除了

下面是一个不同可达性状态的转换图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vOQQrgI0-1682231171120)(C:\Users\Joshua.TV\Pictures\webPhoto_Typora\401e596c8a01c8ea69feebc17f2df229.jpeg)]

判断可达性条件,也是 JVM 垃圾收集器决定如何处理对象的一部分考虑因素。

所有的对象可达性引用都是 java.lang.ref.Reference 的子类,它里面有一个get()方法,返回引用对象。如果已通过程序或垃圾收集器清除了此引用对象,则此方法返回 null 。也就是说,除了幻象引用外,软引用和弱引用都是可以得到对象的。而且这些对象可以人为拯救,变为强引用,例如把 this 关键字赋值给对象,只要重新和引用链上的任意一个对象建立关联即可。

为什么要用分代收集算法

  • 有的对象生命周期长,通常与业务挂钩,比如:Http请求中的Session对象、线程、Socker连接
  • 有的对象生命周期短,比如String对象,如果有大量拼接操作,系统就会产生大量这种对象

如果不进行对象存货时间区分,每次垃圾回收都是对整个堆空间进行回收,会花费很长时间。每次回收都需要遍历所有存活对象,开销较大,对于生命周期长的对象,这种遍历是多余的操作。因此分代垃圾回收采用分治思想,进行代的划分

什么是分代收集算法

根据对象存活周期的不同将内存划分为几块。一般分为新生代和老年代

  • 在新生代,每次垃圾收集时都有大量对象死去,只有少量存活,就选用复制算法,只需付出少量存活对象的复制成本就可以完成收集
  • 在老年代,对象存活率高,没有额外空间对它进行分配担保,就必须使用**“标记清理”或者“标记整理”算法**来进行回收

分代收集下年轻代和老年代应该采用什么样的垃圾回收算法

  • 年轻代(Young Generation)的回收算法以Copying为主
    1. 所有新生成的对象首先都是放在年轻代
    2. 新生代内存按照8:1:1分为一个eden区和两个survivr区,大部分对象哎Eden区中生成。
    3. 回收时先将Eden区存活对象复制到survivor0区,然后清空eden区,
    4. 当survivor0区也存满了,将eden区和survivor0区存活对象复制到survivor1区,然后清空eden区和survivor0区,然后将survivor0区和survivor1区交换数据,即保持survivor1区为空,如此往复
    5. 当survivor1区不足以存放eden区和survivor0区的存活对象时,就将存活对象直接放到老年代
    6. 如果老年代也满了就会触发一次Full GC(Major GC),也就是新生代老年代都进行回收
    7. 新生代发生的GC也叫做Minor GC,发生频率较高,不一定等Eden区满了才触发
  • 老年代(Old Generation)的回收算法以Mark-Compact为主
    1. 在年轻代中经历了N次垃圾回收后仍然存活的对象,就会被放在年老代中
    2. 内存比新生代大很多(大概比例是1:2),当老年代内存满时触发Major GC,频率较低,老年代对象存活时间长,存活率标记高

出现full gc定位问题的步骤

程序执行了System.gc()

  1. 执行了jmap -histo:live pid命令
  2. 在执行minor gc的时候进行的一系列检查
    执行Minor GC的时候,JVM会检查老年代中最大连续可用空间是否大于了当前新生代所有对象的总大小。 如果大于,则直接执行Minor GC(这个时候执行是没有风险的)。 如果小于了,JVM会检查是否开启了空间分配担保机制,如果没有开启则直接改为执行Full GC。 如果开启了,则JVM会检查老年代中最大连续可用空间是否大于了历次晋升到老年代中的平均大小,如果小于则执行改为执行Full GC。 如果大于则会执行Minor GC,如果Minor GC执行失败则会执行Full GC
  3. 使用了大对象
  4. 在程序中长期持有了对象的引用

3.排查步骤

注意: JVM在执行dump操作的时候是会发生stop the word事件的,也就是说此时所有的用户线程都会暂停运行。

3.1通过JVM参数获取dump文件

3.2通过JDK自带的工具jmap获取dump文件

什么是浮动垃圾

由于CMS并发清理阶段用户线程还在运行,程序运行就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉他们,只好留给下一次GC时再清理掉。这一部分垃圾就是“浮动垃圾”

常用的垃圾回收器

Serial收集器:

这个收集器是一个单线程的收集器,在他进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。“Stop The World”。

Serial Old收集器:

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,老年代毫无疑问使用“标记-整理”算法。

ParNew收集器:

ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、收集算法、、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一样,在实现上,这两种收集器也共用了相当多的代码。

Parallel Scavenge收集器:

新生代收集器,毫无疑问使用复制算法的,是并行的多线程收集器,看上去和ParNew都一样

CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。由于与吞吐量关系密切,Parallel Scavenge收集器也经常称为**“吞吐量优先”收集器。**

Parallel Old:Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。

CMS收集器:

HotSpot VM第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。从名字(Concurrent Mark Sweep)上就可以看出,CMS收集器是基于“标记-清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为4个步骤,包括:

  1. 初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要“Stop The World”。
  2. 并发标记阶段就是进行GC Roots Tracing的过程,
  3. 重新标记阶段则是为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。需要“Stop The World”。
  4. 并发清除。由于整个过程中耗时最长的并发标记和并发清除过程,收集器线程都与用户线程可以一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

CMS的优点:并发收集、低停顿。

CMS缺点:

1、CMS收集器对CPU资源非常敏感。其实,面向并发设计的程序都对CPU资源比较敏感。

2、CMS收集器无法处理浮动垃圾。

3、CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。

G1(Garbage-First)收集器:

G1是一款面向服务端应用的垃圾收集器。HotSpot开发团队赋予它的使命是(在比较长期的)未来可以替换掉JDK1.5

中发布的CMS收集器。与其他GC收集器相比,G1具备如下特点。

1、并发与并行

2、分代收集

虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但他能够采用不同的方式去处理新创建的对象和已经存活了

一段时间。熬过多次GC的旧对象以获取更好地收集效果。

3、空间整合

与CMS的“标记-清理”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器,从局部(两个Region之间)上来看是基于

“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。

这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。

4、可预测的停顿

这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型。在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage-First名称的来由)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。

G1收集器的运作大致可划分为以下几个步骤:

1、初始标记 2、并发标记 3、最终标记 4、筛选回收

谈谈你对类加载机制的了解

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。

整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载7个阶段。其中验证、准备、解析3个部分统称为连接。

  • 加载

“加载”是“类加载”过程的一个阶段,在加载阶段,虚拟机需要完成以下3件事情:

1、通过一个类的全限定名来获取定义此类的二进制字节流。

2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构、

3、在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

  • 验证

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。(连接:验证、准备、解析)

  • 准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。

  • 解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

  • 初始化

类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码(或者说是字节码)。在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器()方法的过程。

有哪些类加载器,作用是什么

虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。

类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类加载阶段。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类是来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。

从Java虚拟机的角度来讲,只存在两种不同的类加载器:

  • 启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分;

  • 所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全部继承自抽象类java.lang.ClassLoader。从Java开发人员的角度来看,类加载器还可以划分得更细致一些,绝大部分Java程序都会使用到以下3种系统提供的类加载器。

启动类加载器、扩展类加载器、应用程序类加载器。我们的应用程序都是由这3种类加载器互相配合进行加载的,如果有必要,还可以加入自己定义的类加载器。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JIJaDk3k-1682231171121)(C:\Users\Joshua.TV\Pictures\webPhoto_Typora\c6ba99fae2f73352dbd7dbbdd4bd7b69.png)]

图中展示的类加载器之间的这种层次关系,称为类加载器的双亲委派模型。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父加载器。类加载器之间的关系一般不会以继承的关系实现,而是使用组合关系来复用父加载器的代码。

双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

使用双亲委派模型来组织类加载器之间的关系,好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。

## 你对内存模型的理解

程序在运行过程中,会将运算需要的数据从主存复制一份到 CPU 的高速缓存当中。那么 CPU 进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。而随着 CPU 能力的不断提升,一层缓存就慢慢的无法满足要求了,就逐渐的衍生出多级缓存。按照数据读取顺序和与 CPU 结合的紧密程度,CPU 缓存可以分为一级缓存(L1),二级缓存(L2),部分高端 CPU 还具有三级缓存(L3),每一级缓存中所储存的全部数据都是下一级缓存的一部分。这三种缓存的技术难度和制造成本是相对递减的,所以其容量也是相对递增的。那么,在有了多级缓存之后,程序的执行就变成了:当 CPU 要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。

并发编程:原子性、有序性、可见性

处理器优化:为了使处理器内部的运算单元能够被充分利用,处理器可能会对输入代码进行乱序执行处理。

指令重排:现在很多流行的处理器会对代码进行优化乱序处理,很多编程语言的编译器也会有类似的优化,比如 Java 虚拟机的即时编译器(JIT)也会做指令重排。

缓存一致性问题就是可见性问题,处理器优化会导致原子性问题,指令重排会导致有序性问题。

什么是计算机内存模型

缓存一致性问题、处理器优化的指令重排问题是硬件的不断升级导致的。那么,有没有什么机制可以很好的解决上面的这些问题呢?

最简单直接的做法就是废除处理器和处理器的优化技术、废除 CPU 缓存,让 CPU 直接和主存交互。但是,这么做虽然可以保证多线程下的并发问题。但是,这就有点因噎废食了。所以,为了保证并发编程中可以满足原子性、可见性及有序性。有一个重要的概念,那就是——内存模型。

为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。它解决了 CPU 多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。

内存模型解决并发问题主要采用两种方式:

  • 限制处理器优化
  • 使用内存屏障

本文就不深入底层原理来展开介绍了,感兴趣的朋友可以自行学习。

什么是 Java 内存模型

前面介绍了计算机内存模型,这是解决多线程场景下并发问题的一个重要规范。

那么具体的实现是如何的呢?不同的编程语言,在实现上可能有所不同。

我们知道,Java 程序是需要运行在 Java 虚拟机上面的,Java 内存模型(Java Memory Model,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了 Java 程序在各种平台下对内存的访问都能保证效果一致的机制及规范。Java 内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存。线程的工作内存中保存了该线程中用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。而 JMM 就作用于工作内存和主存之间数据同步过程。它规定了如何做数据同步以及什么时候做数据同步。这里面提到的主内存和工作内存,读者可以简单的类比成计算机内存模型中的主存和缓存的概念。特别需要注意的是,主内存和工作内存与 JVM 内存结构中的 Java 堆、栈、方法区等并不是同一个层次的内存划分,无法直接类比。

Java 内存模型的实现

在 Java 中提供了一系列和并发处理相关的关键字,比如 Volatile、Synchronized、Final、Concurren 包等。其实这些就是 Java 内存模型封装了底层的实现后提供给程序员使用的一些关键字。在开发多线程的代码的时候,我们可以直接使用 Synchronized 等关键字来控制并发,这样就不需要关心底层的编译器优化、缓存一致性等问题。所以,Java 内存模型,除了定义了一套规范,还提供了一系列原语,封装了底层实现后,供开发者直接使用。

我们前面提到,并发编程要解决原子性、有序性和一致性的问题。下面我们就再来看下,在 Java 中,分别使用什么方式来保证。

原子性

在 Java 中,为了保证原子性,提供了两个高级的字节码指令 Monitorenter 和 Monitorexit。

在 Synchronized 的实现原理文章中,介绍过,这两个字节码,在 Java 中对应的关键字就是 Synchronized。

因此,在 Java 中可以使用 Synchronized 来保证方法和代码块内的操作是原子性的。

有序性

在 Java 中,可以使用 Synchronized 和 Volatile 来保证多线程之间操作的有序性。

实现方式有所区别:Volatile 关键字会禁止指令重排。Synchronized 关键字保证同一时刻只允许一条线程操作。

可见性

Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。Java 中的 Volatile 关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存。被其修饰的变量在每次使用之前都从主内存刷新。因此,可以使用 Volatile 来保证多线程操作时变量的可见性。除了 Volatile,Java 中的 Synchronized 和 Final 两个关键字也可以实现可见性。只不过实现方式不同,这里不再展开了。

读者可能发现了,好像 Synchronized 关键字,可以同时满足以上三种特性,这也是很多人滥用 Synchronized 的原因。

但是 Synchronized 是比较影响性能的,虽然编译器提供了很多锁优化技术,但是也不建议过度使用。

其他

栈内存的数据共享

基本类型变量分析

int x = 1;
int y = 1;
x = 2;

处理步骤:

  1. 创建变量x的引用
  2. 在虚拟机栈中查找1这个值
  3. 没找到,存放1,让指向1
  4. 创建变量y的引用
  5. 找到栈中的1,y直接指向1
  6. x的指向变为2,首先在栈中找2,没有2,存放后被指向

以上是数据的共享,这种情况a的修改并不会影响到b,有利于节省内存空间,但如果是两个对象的引用同时指向一个对象,一个对象的改变会影响到另一个对象的引用变量。

引用类型变量分析

String str = new String("aaa");
String str = "aaa";

以上两种创建方式,看似结果是一样的,但是创建的过程是不同,第一种用new()来新建对象,它会放于堆中,每new一次,就新建一个对象;第二种是在栈中创建一个引用变量str,然后查找栈中是否有“aaa”,若有直接将引用变量str指向“aaa”,若没有,则先存放aaa后并指向它。

测试两个包装类的引用是否指向同一个对象时,用==,对比值或字符串的时候用equals()。

String str1 = "bbb";
String str2 = "bbb";
System.out.println(str1==str2); 	//true

上面的运行结果为true,即str1和str2是指向同一个对象的。

String str1 = new String ("ccc");
String str2 = new String ("ccc");
System.out.println(str1==str2); 	//false

上面的运行结果为false,即str1和str2是指向不同的对象,每new一次生成一个对象。

所以String s = "a"这种方式创建字符串,无论创建几个对象,在内存中其实只存在一个对象而已,数据进行了共享,节省的内存空间;而用new()方法才能保证每创建一次就是一个新的对象。就是因为String的不可变性,字符串连接效率低,每次连接都新建字符串对象,可以用StringBuffer或StringBuilder来代替,StringBuffer线程安全,但是效率低,适用于多线程;而StringBuilder线程不安全但是效率高,适用于不考虑线程安全的单线程环境。

JVM常量池与运行时常量池

一、类的二进制字节码包含哪些信息?

  1. 常量池

  2. 类的基本信息(比如:类的访问权限、类的名称、实现了哪些接口)

  3. 类的方法定义(包含了虚拟机指令,也就是把我们代码编译为了虚拟机指令 )

二、通过反编译字节码验证
1、测试代码
将下面的测试代码使用javac 编译为 *.class文件

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("hello world");
    }
}

2、javap反编译*.class字节码
先将示例代码编译为 *.class 文件,然后将class文件反编译为JVM指令码。然后观察 *.class字节码中到底包含了哪些部分。

// ===========================================类的描述信息===============================================
Classfile /xx/xx/xx/xx/HelloWorld.class
  Last modified 2021-10-12; size 569 bytes
  MD5 checksum 7f4f0fe4b6e6d04ddaf30401a7b04f07
  Compiled from "HelloWorld.java"
public class org.memory.jvm.t5.HelloWorld
  minor version: 0
  major version: 49
  flags: ACC_PUBLIC, ACC_SUPER
    
// ===========================================常量池===============================================
Constant pool:
   #1 = Methodref          #6.#20         // java/lang/Object."<init>":()V
   #2 = Fieldref           #21.#22        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #23            // hello world
   #4 = Methodref          #24.#25        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #26            // org/memory/jvm/t5/HelloWorld
   #6 = Class              #27            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lorg/memory/jvm/t5/HelloWorld;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               HelloWorld.java
  #20 = NameAndType        #7:#8          // "<init>":()V
  #21 = Class              #28            // java/lang/System
  #22 = NameAndType        #29:#30        // out:Ljava/io/PrintStream;
  #23 = Utf8               hello world
  #24 = Class              #31            // java/io/PrintStream
  #25 = NameAndType        #32:#33        // println:(Ljava/lang/String;)V
  #26 = Utf8               org/memory/jvm/t5/HelloWorld
  #27 = Utf8               java/lang/Object
  #28 = Utf8               java/lang/System
  #29 = Utf8               out
  #30 = Utf8               Ljava/io/PrintStream;
  #31 = Utf8               java/io/PrintStream
  #32 = Utf8               println
  #33 = Utf8               (Ljava/lang/String;)V
                            
// =======================================虚拟机中执行编译的方法===========================================
{
  public org.memory.jvm.t5.HelloWorld();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 7: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lorg/memory/jvm/t5/HelloWorld;
	
  // main方法JVM指令码
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    // main方法访问修饰符描述
    flags: ACC_PUBLIC, ACC_STATIC
    // main方法中的代码执行部分
    // ===============================解释器读取下面的JVM指令解释并执行===================================             
    Code:
      stack=2, locals=1, args_size=1
         // 从常量池中符号地址为 #2 的地方,先获取静态变量System.out
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         // 从常量池中符号地址为 #3 的地方加载常量 hello world
         3: ldc           #3                  // String hello world
         // 从常量池中符号地址为 #3 的地方获取要执行的方法描述,并执行方法输出hello world
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         // main方法返回
         8: return
    // ==================================解释器读取上面的JVM指令解释并执行================================
      // 行号映射表
      LineNumberTable:
        line 9: 0
        line 10: 8
      // 局部变量表
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;
}


三、什么是常量池以及常量池的作用?
1、什么是常量池
从上面的反编译字节码中可以看到,Class的常量池其实就是一张记录着该类的一些常量、方法描述、类描述、变量描述信息的表

2、常量池中有什么内容
常量池中主要存放两类数据,一是字面量、二是符号引用。

  • 字面量:比如String类型的字符串值或者定义为final类型的常量的值。
  • 符号引用:
    1. 类或接口的全限定名(包括他的父类和所实现的接口)
    2. 变量或方法的名称
    3. 变量或方法的描述信息
    4. this

3、常量池的作用
在解释器解释执行每条JVM指令码的时候,根据这些指令码的符号地址去常量池中找到对应的描述。然后解释器就知道该执行哪个类的那个方法、方法的参数是什么等。

拿上面反编译的字节码指令来说明:

  1. 当解释器解释执行main方法的时候,读取到下面的11行JVM指令码0: getstatic #2
  2. getstatic指令表示获取一个静态变量,#2表示该静态变量的符号地址,解释器通过#2符号地址去常量池中查找#2对应的静态变量
  3. 然后解释器继续向下运行,执行第13行的3: ldc #3指令,该指令的含义是:从常量池中加载符号地址为 #3 的常量
  4. 然后解释器继续向下运行,执行第15行的5: invokevirtual #4指令,该指令的含义是:执行方法,那么要执行哪个方法呢?执行常量池中符号地址为 #4 的方法。
  // main方法JVM指令码
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    // main方法访问修饰符描述
    flags: ACC_PUBLIC, ACC_STATIC
    // main方法中的代码执行部分
    // ===============================解释器读取下面的JVM指令解释并执行===================================             
    Code:
      stack=2, locals=1, args_size=1
         // 从常量池中符号地址为 #2 的地方,先获取静态变量System.out
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         // 从常量池中符号地址为 #3 的地方加载常量 hello world
         3: ldc           #3                  // String hello world
         // 从常量池中符号地址为 #3 的地方获取要执行的方法描述,并执行方法输出hello world
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         // main方法返回
         8: return
    // ==================================解释器读取上面的JVM指令解释并执行================================

四、运行时常量池
1、什么是运行时常量池
上面我们分析了常量池其实就是一张对照表,常量池是 *.class 文件中的。当类的字节码被加载到内存中后,他的常量池信息就会集中放入到一块内存,这块内存就称为运行时常量池,并且把里面的符号地址变为真实地址

2、符号地址变为真实地址怎么理解
①、符号地址
从上面的反编译后的JVM字节码指令可以看到有这么一条指令0: getstatic #2,解释器解释执行JVM指令的时候,通过指令中的 #x去常量池中获取需要的值。这里的#2其实就是符号地址,标识这某个变量在常量池中的某个位置。

②、真实地址
在程序运行期,当*.Class文件被加载到内存以后,常量池中的这些描述信息就会被放到内存中,其中的 #x会被转化为内存中的地址(真实地址)。
符号地址变为真实地址其实就是,在*.class文件被加载到内存以后,将*.class文件中常量池中的#x符号地址,转化为内存中的地址。

总结

没什么好总结的

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值