面试准备-JVM

JVM

JVM

1.JVM中的结构

JAVA的堆栈

  • 在编译原理的描述中,程序运行分为了三种分配内存策略。

    1. 静态的:在编译时就能确定每个数据目标在运行时刻的存储空间需求。

    2. 栈式存储:也可以成为动态存储,是由一个类似于堆栈的运行栈来实现的。在过程入口处需要知道所有存储要求。

    3. 堆式存储:专门负责在编译时或运行时模块入口处无法确定的存储大小的数据结构的内存分配。

      因此,堆主要来存放对象,而栈主要用来执行程序。

JVM中的堆栈

JVM是基于堆栈的虚拟机,JVM会为每个进程分配一个堆栈。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qJydZzMy-1597617357354)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200803141048240.png)]

运行时数据区

虚拟机在执行Java程序的过程中,会把它所管理的内存划分为若干不同的数据区,这些数据区域有着自己的用途,创建和销毁时间。

其中可以以线程来进行划分:

  • 线程共享的:堆,方法区。
  • 线程私有的:程序计数器,虚拟机栈,本地方法栈

程序计数器是什么?

程序计数器就是一块较小的内存区域,可以看做当前线程所执行行字节码的指示器。字节码解释器通过取程序计数器的进行取指令。

同时也能保证线程切换的时候,指令执行回到原来的执行位置。当执行native方法时会为空。程序计数器是Java中唯一没有内存溢出的地方。

Java虚拟机栈的作用?

Java虚拟机栈用于描述Java的内存模型,每当有新线程创建就会分配一个栈空间,线程结束后被回收。

Java程序中的每个方法在执行的时候会创建相应的一个栈帧来存储方法的局部变量表,操作数栈,动态链接,方法出口等信息。每个方法从调用到执行完成,就是栈帧从入栈到出栈的过程。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VFjcFHQp-1597617357369)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200809131814260.png)]

有两类异常:

  • ① 线程请求的栈深度大于虚拟机允许的深度抛出 StackOverflowError。

  • ② **如果 JVM 栈容量可以动态扩展,**栈扩展无法申请足够内存抛出 OutOfMemoryError(HotSpot 不可动态扩展,不存在此问题。

本地方法栈的作用

本地方法栈的作用和虚拟机栈的作用类似。但是主要的为本地方法进行服务。

堆的作用?

  • 虚拟机管理中最大的一部分内存。在物理上可能不连续,但是在逻辑上是连续的。

  • 所有线程创建的对象实例都会放到这里。但是随着JIT编译器的发展和逃逸分析技术,TLAB(Thread Local Allocation Buffer)线程私有的缓存区,有时对象实例也不一定放在这里。

    逃逸分析:是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

    当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。

    堆既可以被实现成固定大小,也可以是可扩展的,可通过 -Xms-Xmx 设置堆的最小和最大容量,当前主流 JVM 都按照可扩展实现。如果堆没有内存完成实例分配也无法扩展,抛出 OutOfMemoryError。

方法区的作用

方法区用于存储被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

JDK8 之前使用永久代实现方法区,容易内存溢出,因为永久代有 -XX:MaxPermSize 上限,即使不设置也有默认大小。JDK7 把放在永久代的字符串常量池、静态变量等移出,JDK8 中永久代完全废弃,改用在本地内存中实现的元空间代替,把 JDK 7 中永久代剩余内容(主要是类型信息)全部移到元空间。

虚拟机规范对方法区的约束宽松,除和堆一样不需要连续内存和可选择固定大小/可扩展外,还可以不实现垃圾回收。垃圾回收在方法区出现较少,主要目标针对常量池和类型卸载**。如果方法区无法满足新的内存分配需求,将抛出 OutOfMemoryError。**

运行时常量池的作用

  1. 运行时常量池是方法区的一部分。
  2. Class文件中除了有类的版本,字段,方法和接口信息等还有常量池表,用于存放编译器生成的各种字面符号引用,这部分内容在类加载后存放到运行时常量池。一般除了保存Class文件中描述的符号引用外,还把符号引用翻译的直接引用存放在运行时常量池。
  3. 运行时常量池对于class文件来说说具有动态性,常量不仅能在编译期间产生也能在运行时产生,这种特性利用的比较多的就是String的intern方法。

String 的 intern 方法是一个本地方法,作用是如果字符串常量池中已包含一个等于此 String 对象的字符串,则返回池中这个字符串的 String 对象的引用,否则将此 String 对象包含的字符串添加到常量池并返回此 String 对象的引用。

  1. 运行时常量池是方法区的一部分,受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError。

直接内存是什么?

直接内存不属于运行时数据区,也不是虚拟机规范中的内存区域,但是也频繁的使用,也会产生OOM。

直接内存的分配不受 Java 堆大小的限制,但还是会受到本机总内存及处理器寻址空间限制,一般配置虚拟机参数时会根据实际内存设置 -Xmx 等参数信息,但经常忽略直接内存,使内存区域总和大于物理内存限制,导致动态扩展时出现 OOM。

2.OOM(Out of Memory)内存溢出

内存溢出和内存泄漏的区别

OOM(内存溢出 OutOfMemory)指的是程序程序在申请内存的时候,没有足够的内存空间能够其使用。

Memory Leak(内存泄漏)指的是程序在申请内存后,无法释放已经申请的内存,内存泄漏最终将导致内存溢出。

堆溢出的原因?

  1. 堆用于存储对象实例,只要不断创建对象并保证 GC Roots 到对象有可达路径避免垃圾回收,随着对象数量的增加,总容量触及最大堆容量后就会 OOM,例如在 while 死循环中一直 new 创建实例。
  2. 堆 OOM 是实际应用中最常见的 OOM,处理方法是通过内存映像分析工具对 Dump 出的堆转储快照分析,确认内存中导致 OOM 的对象是否必要,分清到底是内存泄漏还是内存溢出。

如果是内存泄漏,通过工具查看泄漏对象到 GC Roots 的引用链,找到泄露对象是通过怎样的引用路径、与哪些 GC Roots 关联才导致无法回收,一般可以准确定位到产生内存泄漏代码的具体位置。

如果不是内存泄漏,即内存中对象都必须存活,应当检查 JVM 堆参数,与机器内存相比是否还有向上调整的空间。再从代码检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行期的内存消耗。

栈溢出的原因

如果JVM栈可以动态扩展,(这里为什么可以提动态扩展,是因为HotSpot不支持动态扩展)当扩展无法申请到足够的内存时,就会抛出OOM。

栈还有一个异常就是StackOverflowError:如果线程请求的栈深度大于运行的深度就会产生该异常。如递归方法的调用。

由于 HotSpot 不区分虚拟机和本地方法栈,设置本地方法栈大小的参数没有意义,栈容量只能由 -Xss 参数来设定。HotSpot 不支持虚拟机栈扩展,所以除非在创建线程申请内存时就因无法获得足够内存而出现 OOM,否则在线程运行时是不会因为扩展而导致溢出的。

运行时常量池溢出

在JDK7之前如果使用死循环调用String的intern方法,会导致OOM,但是由于已经移除了永久代放到了堆中,所以这种情况就不会发生了。

方法区溢出的原因

由于方法区主要存放大量的类信息,因此只要不停的创建类,就会导致OOM。比如使用CGLIB去进行动态代理直接操作字节码在运行时就容易产生OOM。

https://www.cnblogs.com/ThinkVenus/p/6805495.html

3.JVM垃圾处理

JVM垃圾处理算法

  • 引用计数法:创建对象时,关联一个与之相对应的计数器,如果被引用就将其+1,销毁就-1。当为0的时候就表示对象没有被使用可以被销毁。该算法存在循环引用问题。

  • 可达性分析:从GC Roots出发进行递归的遍历,如果没有遍历到就说明没有任何引用链表可以到达,因此可以被垃圾回收器清理。

    GC Roots的值可以是:

    • 局部变量表,
    • 栈帧中的本地变量,
    • 方法区汇总静态属性,常量应用的对象,
    • 本地方法栈中JNI引用的对象,
    • 被同步锁持有的对象

Java对于小对象的内存分配

  • TLAB:Eden中的一块线程私有空间,Thread Local Allocation Buffer上进行创建区创建,而不是在共享堆中创建
  • JIT优化逃逸:JIT即时编译会自己进行优化,决定是否在栈上进行新建对象。而不是堆上,这就是逃逸了。

垃圾回收判断算法

  • 引用计数器

  • 可达性分析

    可达性分析中的GC Root

    • 虚拟机栈中的引用对象
    • 方法区中的静态属性引用对象
    • 方法区中的常量引用
    • 本地方法栈中引用对象

Java中的引用

  1. 强引用:类似于使用new新建一个对象的引用。这类强引用还在,垃圾回收器就不会回收它。

  2. 软引用:用来描述一些有用但是并非必须的对象。

  3. 弱引用:也是用来描述非必须的对象的。

  4. 虚引用:是最弱的引用关系。一个对象是否有虚引用的存在,都不会影响他生存时间。

软引用,弱引用,虚引用的区别:

软引用在系统将要发生内存溢出前才会将其列入可回收的范围。而弱引用只能活到下一次GC前。虚引用必须和应用引用

垃圾回收算法

  1. 标记-清除算法:从根集合进行扫描,存活的就标记。然后回收时回收未被标记的。但会产生大量的内存碎片。

  2. 复制算法:将堆分为了两面,一个对象面,一个空闲面。然后每次只使用对象面。每次回收时将活着的对象复制放到空闲面那边。回收对象面中的。回收结束,两个面就转换了角色

  3. 标记-整理算法:标记过程和标记清除一样,但是回收时将存活的对象向左端的空闲区移动。解决了内存碎片问题。

  4. 分代收集算法:目前很多垃圾回收器使用的算法。

    • 年轻代由于存活对象较少,分为了Eden区,servivor0,servivor1。每次收集的时采用复制回收算法,将存活的放入到其中一个servivor区中。然后清空Eden和另一个存放了对象的区域。

    • 老年代:由于存活的比较多,因此采用的是标记整理算法。

垃圾回收器

  • G1回收器

  • CMS回收器

  • Serial回收器:(复制算法)单线程

  • Serial Old收集器(标记整理算法)

  • ParNew收集器:可以认为是Serial的多线程版本

  • Paralle收集器(停止-复制算法):并行收集器,追求高吞吐量,高效利用CPU。是Server级别默认采用的GC方式。

  • ParalleOld回收器:

  • ZGC收集器

CMS回收器

CMS是一种以获得最短垃圾回收停顿时间为目标的垃圾收集器。而实现这个目标的原理在于:CMS回收器工作时,GC工作线程可以和用户线程并发执行,以此来达到降低停顿时间的目标。

  1. CMS作用于老年代,采用的是标记清理垃圾回收算法。它的运作过程分为了:
  • 初始标记:标记GC Root能关联到的对象

  • 并发标记:GC Root Tracing的过程

  • 重新标记:标记并发标记过程中用户程序可能变化的那部分

  • 并发清除

    在初始标记和重新标记的过程中需要Stop-World。

  1. CMS能做到并发的根本原因在于采用基于“标记-清除"算法,并对算法过程进行了细粒度的分解。

3.安全点:程序执行时并非任何时间都能停下来开始GC,只有到达了安全点才能暂停。

  • 安全点的选择原则是“是否具有让程序长时间执行的特征”为标准。即指令序列复用处,如:方法调用,循环跳转,异常跳转等。

  • 两种解决方案:

  • 抢先式中断(Preemptive Suspension)

抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机采用这种方式来暂停线程从而响应GC事件。

  • 主动式中断(Voluntary Suspension)

主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

G1垃圾回收器

https://zhuanlan.zhihu.com/p/52841787?from_voters_page=true

G1回收器整体基于标记整理算法,G1回收器打破了原有的分代模型。将堆划分为一个个区域。在这个基础上采用了停顿时间可预测模型。因此就具有了CMS不具备的预测停顿时间功能,吞吐量也更加的大。

  1. G1垃圾回收器的运行流程:
  • 初始标记:从GC Root进行可达性分析。
  • 并发标记:从GC Roots开始堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长
  • 最终标记:修正并发标记中程序执行导致的变化。
  • 筛选回收:对各个区域的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。

初始标记和最终标记都会stop-world但是时间很短暂。

  1. G1回收器引入了记忆集用于跟踪对象引用。

  2. G1 比 ParallelOld 和 CMS 会需要更多的内存消耗,那是因为有部分内存消耗于簿记(accounting)上,如以下两个数据结构:

    • 记忆集
    • 收集集合:要被回收的区块集合

ZGC

一款很新的收集器。出现在JDK11,被很多人誉为未来将会使用的ZGC。

主要特点就是低停顿,而且停顿时间不会随着堆范围增加而增加。支持堆的范围也很大(8MB-16TB)。

基于 Region 内存布局,不设分代,使用了读屏障、染色指针和内存多重映射等技术实现可并发的标记-整理,以低延迟为首要目标。

ZGC 的 Region 具有动态性,是动态创建和销毁的,并且容量大小也是动态变化的。

GC什么时候触发

GC分为了Minior和Full GC

  • Minior GC是针对年轻代的Eden区的GC。一般情况下,新对象申请空间失败就会触发Scavenge GC。

  • Full GC:对整个堆进行整理。导致Full GC的原因:

  1. 老年代被写满
  2. 持久代被写满
  3. System.gc被调用
  4. 上一次GC后堆的分配策略动态的变化。

判断对象是否已经可回收

当对象不可达到时,不会直接进行回收,他需要经历两次标记的过程。

第一次如果没有GC Root的引用链就会被标记。

第二次判断对象是否有必要执行finalize方法。如果fianlize中没有建立与GR Root的引用就会被第二次标记。如果有了就可以逃过被回收的命运。

常量被回收

在常量池中的对象如果没有被引用的话,就是废弃常量。在回收时就会被回收了。

类的回收

方法区中主要回收的是类。满足以下三个条件的类,就应该被回收:

  • Java堆中没有了这个类的实例。
  • 加载该类的类加载器已经被回收。
  • 类对应的java.lang.Class对象没有被引用

内存分配和回收策略

对象优先在 Eden 区分配

大多数情况下对象在新生代 Eden 区分配,当 Eden 没有足够空间时将发起一次 Minor GC。

大对象直接进入老年代

大对象指需要大量连续内存空间的对象,典型是很长的字符串或数量庞大的数组。大对象容易导致内存还有不少空间就提前触发垃圾收集以获得足够的连续空间。

HotSpot 提供了 -XX:PretenureSizeThreshold 参数,大于该值的对象直接在老年代分配,避免在 Eden 和 Survivor 间来回复制。

长期存活对象进入老年代

虚拟机给每个对象定义了一个对象年龄计数器,存储在对象头。如果经历过第一次 Minor GC 仍然存活且能被 Survivor 容纳,该对象就会被移动到 Survivor 中并将年龄设置为 1。对象在 Survivor 中每熬过一次 Minor GC 年龄就加 1 ,当增加到一定程度(默认15)就会被晋升到老年代。对象晋升老年代的阈值可通过 -XX:MaxTenuringThreshold 设置。

动态对象年龄判定

为了适应不同内存状况,虚拟机不要求对象年龄达到阈值才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 的一半,年龄不小于该年龄的对象就可以直接进入老年代。

空间分配担保

MinorGC 前虚拟机必须检查老年代最大可用连续空间是否大于新生代对象总空间,如果满足则说明这次 Minor GC 确定安全。

如果不满足,虚拟机会查看 -XX:HandlePromotionFailure 参数是否允许担保失败,如果允许会继续检查老年代最大可用连续空间是否大于历次晋升老年代对象的平均大小,如果满足将冒险尝试一次 Minor GC,否则改成一次 FullGC。

冒险是因为新生代使用复制算法,为了内存利用率只使用一个 Survivor,大量对象在 Minor GC 后仍然存活时,需要老年代进行分配担保,接收 Survivor 无法容纳的对象。

3.JDK工具

JDK命令行工具

jps:虚拟机进程状况工具

功能和 ps 命令类似:可以列出正在运行的虚拟机进程,显示虚拟机执行主类名称以及这些进程的本地虚拟机唯一 ID(LVMID)。LVMID 与操作系统的进程 ID(PID)一致,使用 Windows 的任务管理器或 UNIX 的 ps 命令也可以查询到虚拟机进程的 LVMID,但如果同时启动了多个虚拟机进程,必须依赖 jps 命令。

jstat:虚拟机统计信息监视工具

用于监视虚拟机各种运行状态信息。可以显示本地或远程虚拟机进程中的类加载、内存、垃圾收集、即时编译器等运行时数据,在没有 GUI 界面的服务器上是运行期定位虚拟机性能问题的常用工具。

参数含义:S0 和 S1 表示两个 Survivor,E 表示新生代,O 表示老年代,YGC 表示 Young GC 次数,YGCT 表示 Young GC 耗时,FGC 表示 Full GC 次数,FGCT 表示 Full GC 耗时,GCT 表示 GC 总耗时。

jinfo:Java 配置信息工具

实时查看和调整虚拟机各项参数,使用 jps 的 -v 参数可以查看虚拟机启动时显式指定的参数,但如果想知道未显式指定的参数值只能使用 jinfo 的 -flag 查询。

jmap:Java 内存映像工具

用于生成堆转储快照,还可以查询 finalize 执行队列、Java 堆和方法区的详细信息,如空间使用率,当前使用的是哪种收集器等。和 jinfo 一样,部分功能在 Windows 受限,除了生成堆转储快照的 -dump 和查看每个类实例的 -histo 外,其余选项只能在 Linux 使用。

jhat:虚拟机堆转储快照分析工具

JDK 提供 jhat 与 jmap 搭配使用分析 jmap 生成的堆转储快照。jhat 内置了一个微型的 HTTP/Web 服务器,生成堆转储快照的分析结果后可以在浏览器查看。

jstack:Java 堆栈跟踪工具

用于生成虚拟机当前时刻的线程快照。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的目的通常是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间挂起等。线程出现停顿时通过 jstack 查看各个线程的调用堆栈,可以获知没有响应的线程在后台做什么或等什么资源。

JDK可视化工具:

Jconsole:java监控与管理平台

Visual VM多合一故障处理工具

类加载

new一个对象的过程

从执行过程创建一个对象

  1. 检查被实例化的类是否已经完成了实例化,

  2. 如果没有,就进入类加载子系统进行类加载

    类加载过程:

    • 加载:将case加载到方法区,并创建相应的数据结构

    • 连接,验证,准备,解析

    • 初始化

  3. 如果类被加载了,就为该类分配内存空间

  4. 为该内存空间完成初始化

  5. 完善对象信息,如:该实例属于那种类型,hashcode,对象分代年龄

  6. 对象初始化,构造函数执行

  7. 对象创建完成

从字节码角度创建一个对象:

  • NEW: 如果找不到 Class 对象则进行类加载。加载成功后在堆中分配内存,从 Object 到本类路径上的所有属性都要分配。分配完毕后进行零值设置。最后将指向实例对象的引用变量压入虚拟机栈顶。
  • **DUP: ** 在栈顶复制引用变量,这时栈顶有两个指向堆内实例的引用变量。两个引用变量的目的不同,栈底的引用用于赋值或保存局部变量表,栈顶的引用作为句柄调用相关方法。
  • INVOKESPECIAL: 通过栈顶的引用变量调用 init 方法,就能使用那个类了。

对象内存分配的方式

对象所需内存大小在类加载完成后便可以确定,对象分配空间也就是指把一块确定大小的内存块从Java堆中分配出来。

主要有两种分配方式:

指针碰撞:假设Java堆中的内存分配还是绝对规整的,指针就作为内存已分配和未分配的分界线。分配内存空间的过程就是作为分界线的指针移动的过程。

空闲列表:如果Java堆中的空间时不规整的,虚拟机就会维护一个空闲链表,用来记录剩余可用的内存空间。每次分配内存后就去动态的维护这个表。

具体使用什么样的分配方式主要是靠堆是否规整来决定的,而堆是否规整则是靠垃圾回收器是否具有空间压缩能力来决定的。使用 Serial、ParNew ,G1等收集器时,系统采用指针碰撞;使用 CMS 这种基于清除算法的垃圾收集器时,采用空闲列表。

对象分配空间是否线程安全?

对象创建这个过程是十分频繁的,他不是一个线程安全的过程。

但是有一些解决方案:

  • ① CAS 加失败重试保证更新原子性。
  • ②每个线程在Java堆中预先分配一小块内存,然后再给对象分配内存的时候,直接在自己这块”私有”内存中分配,当这部分区域用完之后,再分配新的”私有”内存。这种方案被称之为TLAB分配,即Thread Local Allocation Buffer。这部分Buffer是从堆中划分出来的,但是是本地线程独享的。这里也是堆是线程共享的这个结论需要注意的点。

TLAB是虚拟机在堆内存的eden划分出来的一块专用空间,是线程专属的。在虚拟机的TLAB功能启动的情况下,在线程初始化时,虚拟机会为每个线程分配一块TLAB空间,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。

> [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IL1uOpYr-1597617357426)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200812100947851.png)]

对象的内存布局

对象在堆内存的存储布局可分为对象头、实例数据和对齐填充。

  • 对象头:占12B,其中8比特是用来存储对象运行时数据的,如hash值,GC年龄分代,锁标志等。

    还有4Bit是类型指针,对象指向它的类型元数据,占4bit。通过这个来确定是那个对象的实例。

  • 实例数据:对象真正存储的有效信息,即本类对象的实例成员变量和所有可见的父类成员变量。

  • 对齐填充:由于虚拟机要求对象必须是8bit的倍数,因此这部分就用来进行填充,达到要求。

对象的访问方式

Java 程序会通过栈上的 reference 引用操作堆对象,访问方式由虚拟机决定,主流访问方式主要有句柄和直接指针

句柄: 堆会划分出一块内存作为句柄池,reference 中存储对象的句柄地址,句柄包含对象实例数据与类型数据的地址信息。优点是 reference 中存储的是稳定句柄地址,在 GC 过程中对象被移动时只会改变句柄的实例数据指针,而 reference 本身不需要修改。

直接指针: 堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference 存储对象地址,如果只是访问对象本身就不需要多一次间接访问的开销。优点是速度更快,节省了一次指针定位的时间开销,HotSpot 主要使用直接指针进行对象访问。

类的加载

加载->连接->初始化;连接又包含:验证,准备,解析

  • 加载

    通过全类名获取该类的二进制字节流,将字节流转换为运行时数据结构。生成一个class对象,作为方法区数据的访问入口。

  • 验证

文件格式验证-》元数据验证-》字节码验证-》符号引用验证

  • 准备

为类变量分配内存,并赋予初始值,这里的初始值不是实例中给的值而是0,null,false等值。

  • 解析

将符号引用替换为直接引用。主要针对类,接口,类方法,接口方法,方法类型,字段,方法句柄和调用限定7类符号进行解析。

  • 初始化

类加载器

JVM中内置了三个重要的类加载器。除了BootStrapClassLoder其他类加载器均由Java实现且全部继承自java.lang.ClassLoader

  • BootstrapClassLoader(启动类加载器):最顶层的加载器,由C++实现,负责加载**%Java_HOME%/lib**目录下的jar包和类或者被-Xbootclasspath参数指定的路径中的类。
  • ExtensionClassLoader(扩展类加载器):加载**%Java_HOME%/lib/ext**目录下的jar包和类,或被java.ext.dirs系统变量所指定的jar包。
  • AppClassLoader(应用程序类加载器):面向用户的加载器,负责加载当前应用classpath下的所有jar包。也被叫做系统默认加载器

双亲委派机制

类加载器具有等级制度但非继承关系,以组合的方式复用父加载器的功能。双亲委派模型要求除了顶层的启动类加载器外,其余类加载器都应该有自己的父加载器。而每一个类都有自己对应的类加载器。系统中的类加载器会默认使用双亲委派模型。

在类加载的过程中,首先会检查是否已经被加载了,如果已经被加载了,就会直接返回。如果没有被加载,就会进行加载,首先会尝试将请求委派给父类的加载器。每一层都会向上委派,最终到达启动类加载器。如果父加载器无法进行反馈请去,子类加载器才会尝试响应请求。

而这样的过程就会使得类和它的加载器一样,具有优先级的层次关系,能够保证不同环境下都是一样的,保证了类的唯一性,使得程序具有稳定性。

如何判断两个类是否是相等的

  1. 类的唯一性是由类本身和类加载器共同决定的。

  2. 判断类的是否相同可以使用:equals(),isAssiganableForm,isInstance()方法。

破坏双亲委派模型的方法

  • 重写java.lang.ClassLoad类下的ClassLoad()方法。

  • 通过SPI(服务提供者接口)实现。常见的SPI有JDBC,JNDI。

    破坏的原理:SPI是Java核心类库中的一部分,由启动类加载器加载。但是他的实现类系统类加载器加载。按照双亲委派模型,启动类无法委派系统类加载,于是就交给了线程上下文类加载器加载

  • OSGI(开放服务网关协议):是面向Java动态化模块系统类模型。在OSGI中类加载器不再是双亲委派机制,而是一个复杂的网状结构。

4. JMM(Java内存模型)

Java的通信机制

线程的通信是指线程之间以何种机制来交换信息、在编程中,线程的通信机制主要有:

  • 共享内存

    在共享内存的并发模型中,线程之间共享程序的公共状态。线程之间通过读写内存中的公共变量来进行的隐式的通信。

  • 消息传递:

    在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显示通信,在Java中的例子就是wait()和notify()。

JMM是什么?

JMM,Java内存模型,定义了Java虚拟机JVM在计算机内存(RAM)中的工作方式。

Java线程中通过共享内存进行通信由JMM控制。JMM的主要目的是定义程序中各种变量的访问规则。这些变量包括实例字段,静态字段,但不包括局部变量方法参数,因为他们是线程私有的,不存在多线程竞争。

JMM的原则是,只要不改变程序执行的结果,编译器和处理器怎么优化都可以。例如编译器分析某个锁只会单线程访问就消除锁,某个 volatile 变量只会单线程访问就把它当作普通变量。

**JMM 规定所有变量都存储在主内存,每条线程有自己的工作内存,工作内存中保存被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作空间进行,不能直接读写主内存数据。**不同线程间无法直接访问对方工作内存中的变量,线程通信必须经过主内存。

JMM中的许多关键技术点都是围绕着多线程的原子性,可见性和有序性来建立的。

关于主内存与工作内存的交互,即变量如何从主内存拷贝到工作内存、从工作内存同步回主内存,JMM 定义了 8 种原子操作:

操作 作用变量范围 作用
lock 主内存 把变量标识为线程独占状态
unlock 主内存 释放处于锁定状态的变量
read 主内存 把变量值从主内存传到工作内存
load 工作内存 把 read 得到的值放入工作内存的变量副本
user 工作内存 把工作内存中的变量值传给执行引擎
assign 工作内存 把从执行引擎接收的值赋给工作内存变量
store 工作内存 把工作内存的变量值传到主内存
write 主内存 把 store 取到的变量值放入主内存变量中

as-if-serial是什么?

as-if-serial的意思是**:不管怎么重排序。单线程程序的执行结果不能改变**,编译器和处理器必须遵循as-if-serial语义。

为了遵循as-if-serial,编译器和处理器不会对存在数据依赖关系的操作重排序,因为这种重排序会改变执行结果。但是如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

as-if-serial 把单线程程序保护起来,给程序员一种幻觉:单线程程序是按程序的顺序执行的。

指令重排的目的是什么?

计算机指令可能被不同硬件执行,因此就采用流水线技术来执行。流水线的指令执行方式极大提升了CPU的性能。但是流水线怕被中断。而指令重排序只是为了减少中断的一种技术。

如何防止指令重排

  1. 从硬件层面:
  • 通过采用CPU的内存屏障实现。

    如:使用Ifence指令,mfence指令。在X86上使用lock这样的原子指令。

    lfence:load| 在lfence指令前的写操作当必须在lfence指令后的写操作前完成

    mfence:mix| 在mfence指令前的写操作当必须在mfence指令后的写操作前完成

  1. 从JVM层面

    • 使用JVM的屏障。如:LoadLoad屏障,StoreStore屏障,LoadStore屏障,StoreLoad屏障。

      这些操作都是在Java内存模型的8大原子操作的基础上完成的。

    • 用volatile来修饰类成员变量,而volatile的实现靠的其实就是JMM说提供的内存屏障。

    • 不使用synchronize因为JIT优化,可能导致它被优化掉。

happen-before是什么意思?

先行发生原则,JMM定义的两项操作间的偏序关系,是判断数据是否存在竞争的重要手段。

JMM将happen-before要求禁止的重排序按是否会改变程序执行结果分为两类。

  • 对于会改变结果的重排序JMM要求编译器和处理器必须禁止。
  • 对于不会改变排序结果的JMM不要求。

JMM 存在一些天然的 happens-before 关系,无需任何同步器协助就已经存在。如果两个操作的关系不在此列,并且无法从这些规则推导出来,它们就没有顺序性保障,虚拟机可以对它们随意进行重排序。

  • **程序次序规则:**一个线程内在前面的操作先行发生于后面的。
  • 管程锁定规则: unlock 操作先行发生于后面对同一个锁的 lock 操作。
  • **volatile 规则:**对 volatile 变量的写操作先行发生于后面的读操作。 对可见性的保证
  • **线程启动规则:**线程的 start 方法先行发生于线程的每个动作。
  • **线程终止规则:**线程中所有操作先行发生于对线程的终止检测。
  • **对象终结规则:**对象的初始化先行发生于 finalize 方法。
  • 线程的中断:线程的中断先于被中断的代码。
  • **传递性:**如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C 。

as-if-serial 和 happen-before 有什么区别?

as-if-serial保证单线程程序的执行结果不变,happen-before保证正确同步的多线程程序的执行结果不变。

这两种语义的目的都是为了在不改变程序执行结果的前提下尽可能提高程序执行并行度。

什么是原子性,可见性,有序性

  1. 原子性:一个操作是不可中断的,要么全部执行成功要么全部执行失败

    JMM的8大操作都是具有原子性的。正是因为这个read,load,use,assign,store,write的原因,因此基本数据类型的访问都具有原子性,但long和double除外。虚拟机将没有被 volatile 修饰的 64 位数据操作划分为两次 32 位操作。

    而对于unlock,lock操作,JMM没有直接给用户,但是体现在JVM中的体现就是monitorenter 和 monitorexit,在Java中的体现就是synchronized

  2. 可见性:当一个线程修改了共享变量时,其他线程能够立即得知修改。

    JMM 通过在变量修改后将值同步回主内存,在变量读取前从主内存刷新的方式实现可见性,无论普通变量还是 volatile 变量都是如此,区别是 volatile 保证新值能立即同步到主内存以及每次使用前立即从主内存刷新。

    **synchronized 和 final 也可以保证可见性。**同步块可见性由"对一个变量执行 unlock 前必须先把此变量同步回主内存,即先执行 store 和 write"这条规则获得。final 的可见性指:被 final 修饰的字段在构造方法中一旦初始化完成,并且构造方法没有把 this 引用传递出去,那么其他线程就能看到 final 字段的值。

  3. 有序性:在本线程内观察所有操作是有序的,在一个线程内观察另一个线程,所有操作都是无序的。

    前者的有序是因为as-if-serial原则,后者的而无序性是因为指令的重排序与主内存延迟现象。

    Java 提供 volatile 和 synchronized 保证有序性,volatile 本身就包含禁止指令重排序的语义,而 synchronized 保证一个变量在同一时刻只允许一条线程对其进行 lock 操作,确保持有同一个锁的两个同步块只能串行进入。

volatile不具备原子性的原因?

两个原因:

  • 因为volatile修饰的变量read,load,use操作和assign,store,write必须是连续的,执行引擎修改后需要先写到工作内存然后最终写回主存。这里保证了volatile的可见性。
  • 但是在这个操作过程中会使用lock内存屏障,这个操作s是线程安全的,但是前面的read,load,use或者是写会的assign,store,write这些操作不是线程安全的。在执行内存屏障之前,不同 CPU 依旧可以对同一个缓存行持有,一个 CPU 对同一个缓存行的修改不能让另一个 CPU 及时感知,因此出现并发冲突。

volatile的可见性实现原理

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QM4nsn9S-1597617357437)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200807115300704.png)]
在这里插入图片描述

final可以保证可见性吗?

final 可以保证可见性,被 final 修饰的字段在构造方法中一旦被初始化完成,并且构造方法没有把 this 引用传递出去,在其他线程中就能看见 final 字段值。

final保证可见性的原理

主要靠的是final读写域的重排序规则

禁止把 final 域的写重排序到构造方法之外,编译器会在 final 域的写后,构造方法的 return 前插入一个 Store Store 屏障确保在对象引用为任意线程可见之前,对象的 final 域已经初始化过。

在一个线程中,初次读对象引用和初次读该对象包含的 final 域,JMM 禁止处理器重排序这两个操作。编译器在读 final 域操作的前面插入一个 Load Load 屏障,确保在读一个对象的 final 域前一定会先读包含这个 final 域的对象引用。

volatile的有序性的保证

依靠的是JMM提供的内存屏障实现,即在volatile的汇编代码实现上,有lock前缀。

使用 lock 前缀引发两件事:① 将当前处理器缓存行的数据写回系统内存。②使其他处理器的缓存无效。相当于对缓存变量做了一次 store 和 write 操作,让 volatile 变量的修改对其他处理器立即可见。

JMM解决底层缓存不一致的方法

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CmlkRLm1-1597617455206)(C:\Users\Administrator\AppData\Roaming\Typora\typora-user-images\image-20200807162329495.png)]

猜你喜欢

转载自blog.csdn.net/H1517043456/article/details/108047888
今日推荐