Java虚拟机jvm

jvm

Java内存区域

运行时数据区域

在这里插入图片描述
程序计数器:当前线程执行的字节码的行号指示器。如果线程执行的时Java方法,则计数器记录的是正在执行的虚拟机字节码指令的地址;如果执行的是native方法,则为空。每条线程都有一个独立的程序计数器,是存在于线程的私有内存中的
虚拟机栈:描述的是Java方法执行的内存模型,每个方法在执行的同时都会创建一个栈桢用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直到执行完成的过程,就对应一个栈桢在虚拟机栈中入栈到出栈的过程。也是线程私有的。其中的局部变量表所需的内存空间是在编译期就确定的
本地方法栈:native方法服务的栈。也是线程私有的。
:存放对象实例。是垃圾收集器管理的主要区域。可细分为新生代、老年代;堆中还可能划分出多个线程私有的分配缓冲区(TLAB)

  • 1 字符串常量池:是Java堆内存中的一个特殊存储区域,用于存储字符串字面量。当你创建一个字符串字面量时,JVM会首先检查字符串常量池中是否已存在相同的字符串。如果存在,则不会创建新的字符串对象,而是返回对该字符串的引用。如果不存在,则会在字符串常量池中创建一个新的字符串对象,并返回其引用。
    需要注意的是,字符串常量池仅存储字符串字面量。对于通过new操作符创建的字符串对象,它们不会在字符串常量池中存储,而是存储在Java堆中的普通对象区域。这些对象即使内容相同,也是不同的对象,它们的引用不相等。

String a=“zifuchuan”,后面的“zifuchuan”就叫字符串字面量。

方法区:存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码缓存等数据
,又称为永久代(不应该这样叫)

-XX:ReservedCodeCacheSize

  • 2 运行时常量池:是方法区的一部分,class文件中有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用;具备动态性,即运行期间才将新的常量放入池中。
  • 3 方法区的常量池(又名class文件常量池):class类文件的一部分,可以看做一张表,它包含了类在编译期生成的各种字面量(literal)和符号引用(symbolic references)。常量池可以看做一张表,虚拟机指令根据这张常量表找到要执行的类名,方法名,参数类型,字面量等类型。例如,类名、方法名、接口名、文本字符串、final修饰的常量等。当类被虚拟机加载到内存后,JVM就会将class常量池中的内容集中存放到一块内存,这块内存就是运行时常量池,并且把里面的符号地址变为真实地址。

在jvm中,对于类加载时的静态常量,字符串常量存储在堆的字符串常量池,非字符串常量存储在方法区的常量池。

直接内存:不是虚拟机运行时数据区的一部分。NIO中,使用Native函数库直接分配堆外内存,然后通过一个在Java堆中的DirectByteBuffer对象作为引用进行操作,避免了在Java堆和Native堆中来回复制数据,提高了性能

java8的改动-元空间meta space
元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小
-XX:MetaspaceSize设置元空间的初始大小
-XX:MaxMetaspaceSize设置元空间的最大大小

-Xmx和-Xms只是针对堆内存的分配,其他内存区域虚拟机栈、本地方法栈和程序计数器的大小和分配是由JVM根据运行时情况自动管理的。

直接内存
直接内存,也称为堆外内存,不是JVM运行时数据区的一部分,它通过java的NIO类进行分配和管理。
直接内存大小可以通过-XX:MaxDirectMemorySize设置
使用如下方式,创建直接内存:ByteBuffer directBuffer = ByteBuffer.allocateDirect(10);
优点:
1.无需垃圾回收:直接内存并不受Java堆的垃圾回收机制管理,不会占用宝贵的堆空间,也不会对垃圾回收器产生额外的压力。
2.零拷贝:在使用直接内存进行I/O操作时,可以通过零拷贝技术将数据直接从直接内存(内核缓冲区)传输到网络或磁盘上,避免了数据复制的开销,提高了性能。
缺点:
1.分配和释放成本较高:由于直接内存需要与操作系统进行交互,所以它的分配和释放成本相对较高,而且需要谨慎管理以避免资源泄漏。

TLAB
Thread Local Allocation Buffer,即线程本地分配缓存区。
TLAB空间的内存非常小,缺省情况下仅占有整个Eden空间的1%,也可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。
TLAB的本质其实是三个指针管理的区域:start,top 和 end,每个线程都会从Eden分配一块空间,例如说100KB,作为自己的TLAB,其中 start 和 end 是占位用的,标识出 eden 里被这个 TLAB 所管理的区域,卡住eden里的一块空间不让其它线程来这里分配。
TLAB只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。从这一点看,它被翻译为 线程私有分配区 更为合理一点
当一个TLAB用满(分配指针top撞上分配极限end了),就新申请一个TLAB,而在老TLAB里的对象还留在原地什么都不用管——它们无法感知自己是否是曾经从TLAB分配出来的,而只关心自己是在eden里分配的。
TLAB是为了减少不同线程之间因为对象分配而造成的竞争,提高了多线程环境下对象分配的效率。
TLAB的空间大小不是固定的,而是有JVM根据运行情况计算而得。默认情况下,TLAB的大小很小,仅占有整个Eden空间的1%。具体可以通过参数设置 -XX:TLABSize来设置,但请注意,这个参数在某些JVM版本中可能不可用。
注意,当一个线程申请新的TLAB时,老的TLAB相当于退化成了Eden的一部分,因为TLAB只对对象内存分配这一步有影响,对于后续没有影响。
TLAB的缺点
事务总不是完美的,TLAB也又自己的缺点。因为TLAB通常很小,所以放不下大对象。

  • TLAB空间大小是固定的,但是这时候一个大对象,我TLAB剩余的空间已经容不下它了。(比如100kb的TLAB,来了个110KB的对象)
  • TLAB空间还剩一点点没有用到,有点舍不得。(比如100kb的TLAB,装了80KB,又来了个30KB的对象)
    所以JVM开发人员做了以下处理,设置了最大浪费空间。
    当剩余的空间小于最大浪费空间,那该TLAB属于的线程在重新向Eden区申请一个TLAB空间。进行对象创建,还是空间不够,那你这个对象太大了,去Eden区直接创建吧!
    当剩余的空间大于最大浪费空间,那这个大对象请你直接去Eden区创建,我TLAB放不下没有使用完的空间。
    当然,又回造成新的病垢。
  • Eden空间够的时候,你再次申请TLAB没问题,我不够了,Heap的Eden区要开始GC,
  • TLAB允许浪费空间,导致Eden区空间不连续,积少成多。以后还要人帮忙打理
    正是因为有这样的缺点,所以虚拟机主版本并没有使用TLAB,需要设置参数来使用它。

开启TLAB,对象的内存分配流程
1.检查TLAB:检查TLAB中是否有足够内存容纳新对象。如果有,跳转步骤3;否则继续步骤2
2.重新填充TLAB或在Eden区进行常规分配
如果TLAB中的空间不足以容纳新对象,那么虚拟机会进行两种可能的操作之一:一是尝试重新为线程分配一个新的、更大的TLAB;二是如果无法分配新的TLAB或者新的TLAB仍然不足以满足需求,那么线程会退出TLAB模式,直接在堆上进行内存分配
3.在TLAB中分配对象:一旦确定TLAB中有足够空间,线程就会直接在TLAB中分配内存。因为无需与其他线程同步,因此速度非常快
4.初始化对象

new指令对象的创建过程

(1)检查指令的参数能否在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,则先执行类加载过程
(2)接下来为新生对象分配内存。对象所需内存大小在类加载完后便可完全确定。为对象分配空间的任务等同于把一块确定大小的内存从java堆中划分出来

划分内存有两种方法:指针碰撞和空闲列表。选择哪种方法由java堆是否规整决定,而java堆是否规整又由所采用的垃圾收集器是否带有空间压缩能力决定。因此使用Serial、ParNew等带压缩整理过程的垃圾收集器时,系统采用指针碰撞法,即简单又高效;当使用CMS这种基于清除算法的收集器时,理论上只能采用较为复杂的空闲列表法。
分配内存这个行为是需要同步的,虚拟机默认采用CAS加上失败重试保证更新的原子操作;另一种方式是通过TLAB来增加效率

(3)将分配到的内存空间都初始化为零值(这个说法是错误的。对于在声明时就进行了初始化的引用类型变量,它们会被初始化为指定的对象实例,而不是 null。在声明时,没有显示设置初始值的,才会被初始化为零值。),保证对象的实例字段不赋初始值就能使用。如果使用TLAB,这项工作将提前至TLAB分配时顺便进行。例如,数值类型的字段会被设置为0,引用类型的对象会被设置为null。

例如,如下两个变量会被直接初始化为真实值:

public class MyClass {  
    private AnotherClass myObject = new AnotherClass();  
    private int a = 10; 
    public MyClass() {  
    }  
}

注意,类的静态变量,是在类加载阶段初始化的。

(4)对象头的设置,如对象是哪个类的实例、如果找到类的元数据信息、对象的hashCode、对象的GC分代年龄等。还有是否启用偏向锁等,这些信息存放在对象头中
(5)执行构造方法,如果没有定义构造方法,Java会提供一个默认的构造方法。
(6)返回引用。构造方法执行成功后,虚拟机就会返回这个新创建对象的引用。我们就可以通过这个引用来访问和操作对象了。

new指令大致执行过程

if(对象已经初始化){
	l=获取对象长度
	if(配置了TLAB){TLAB中分配对象
	}
	if(对象没分配成功){Eden区分配对象,通过cas+失败重试
	}
	if(分配到内存了){
		为对象初始化零值;
		if(启用了偏向锁){
			设置偏向锁到对象头
		}
		设置对象头
	}
}
对象的内存布局

对象在内存中分为3块区域:对象头、实例数据、对齐填充
对象头:包括两部分信息,一部分用于存储对象自身的运行时数据,如hashCode、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等;另一部分是类型指针,即对象指向它的类元数据的指针。此外,如果对象是一个数组,那么对象头中还必须有一块记录数组长度的数据
实例数据:对象真正存储的有效信息,也就是程序代码中锁定义的各种类型的字段内容
对齐填充:这并不是必然存在的,仅仅起着占位符的作用。任何对象的大小都必须是8字节(byte)的整数倍

对象的访问定位

我们通过reference来操作具体的对象。reference可能在栈的本地变量表,也可能在对象的实例数据里

  • 句柄。Java堆中划分出一块内存作为句柄池,reference中存储的是句柄地址,而句柄则是对象实例数据和类元信息的指针
  • 直接指针。Hotspot使用第二种,定位对象减小一次开销

StackOverflowError:栈溢出。死循环导致虚拟机栈的栈深度无限增长超过Java虚拟机规定的最大深度时会出现
OutOfmemoryError:内存溢出。
内存泄漏:指内存不再使用但却没有清理

String.intern()
public static void main(String[] args) {
        String str1 = new StringBuilder("计算").append("机").toString();
        System.out.println(str1.intern()==str1);//true
        String str2= new StringBuilder("ja").append("va").toString();
        System.out.println(str2.intern()==str2);//false
    }

这段代码在JDK6中运行会得到两个false,而在JDK7中运行,会得到一个true和一个false。产生差异的原因是,在JDK6中,intern()方法会把首次遇到的字符串实例复制到永久带的字符串常量池中存储,返回的也是永久代里的这个字符串实例的引用,而由StringBuilder创建的字符串对象实例再Java堆上,所以必然不可能是同一个饮用,结果返回false。
而JDK7中的intern()方法实现旧不需要拷贝字符串的实例到永久代了,既然字符串常量池已经移到Java堆中,那只需要在常量池里记录首次出现的实例引用即可,因此intern()返回的引用和由StringBuild创建的那个字符串实例就是同一个。而对Str2比较返回false,是因为"java"是一个关键字,字符串常亮池中早已有它的引用。

垃圾收集

主要是针对堆和方法区

对象可用

引用计数法:给对象添加一个引用计数器,当有地方引用它时就加1,引用失效时就减1,计数器值为0就代表不再使用。它很难解决对象之间循环引用的问题
可达性分析法:通过一系列的称为GC Roots的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连,则此对象不可用

GC Roots:虚拟机栈中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中引用的对象

引用类型

java中有值类型,也有引用类型,引用类型一般是针对java中的对象来说的。java为引用类型专门定义了一个类reference
强引用:只要强引用存在,垃圾回收器永远不会回收对象
软引用:用来描述游泳但并非必须的对象,在系统要发生内存溢出之前,会把软引用的对象列进回收范围中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。jdk中提供了SoftReference类实现软引用
弱引用:被弱引用关联的对象只能生存到下一次垃圾回收发生之前。当垃圾收集器工作时,无论内存是否足够,都会回收掉只被弱引用关联的对象。jdk提供WeakReference类来实现弱引用
虚引用:唯一作用是在对象被垃圾收集器回收时收到一个系统通知,跟对象的生存没有任何影响。jdk提供PhantomReference来实现虚引用

垃圾收集算法

Minor GC:新生代收集
Major GC:老年代收集
Full GC:整个java堆的收集
标记-清除算法:分为标记和清除两个阶段:首先标记出所有需要回收的对象,在标记完成之后统一回收所有被标记的对象。缺点:当需要回收的对象数量很大时,效率很低低;空间碎片多
标记-复制算法:将可用内存按容量分成大小相等的两块,每次只使用其中一块,当一块的内存用完了,就将还存活着的对象复制到另一块上,然后再把已使用过的内存一次清理掉。优点:简单,没有空间碎片;缺点:内存缩小为了原来的一半,空间浪费严重

现在的商业虚拟机基本都是采用这个算法来回收新生代,将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor职工还存活的对象一次性复制到另一个Survivor,最后清理掉Eden和用过的Survivor。默认Eden和Survivor比例是8:1,即新生代可用内存空间为整个新生代容量的90%
内存分配担保:如果Survivor空间不够存放上一次新生代收集下来的存活对象,这些对象将直接通过分配担保机制进入老年代

标记-整理算法:根据老年代的特点,标记可回收对象后,让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。缺点:移动存活对象并更新所有引用这些对象的地方是i个极为负重的操作,必须stop the world才能进行。优点:没有空间碎片也没有内存浪费
分代收集算法:把java堆分为新生代和老年代,新生代中使用复制算法,老年代使用标记-清除或标记-整理算法来回收

HotSpot的算法实现
根节点枚举

可达性分析法中,枚举GC Roots节点时,必须保证“一致性”,即整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点引用链还在不断变化的情况,以保证准确性。因此会有一个"Stop The World"的情况

安全点

垃圾回收发生时,让所有的线程都跑到安全点,然后停顿下来。

Stop The World

用户线程在运行至安全点(safe point)或安全区域(safe region)之后,就自行挂起,进入暂停状态,对外的表现看起来就像是全世界都停止运转了一样

垃圾收集器

在这里插入图片描述
新生代收集器:
Serial收集器:采用复制算法,新生代单线程收集器,优点是简单高效,缺点在工作时必须停止所有其他工作线程

在这里插入图片描述
ParNew收集器:采是Serial收集器的多线程版本,除了使用多个线程进行垃圾收集之外,其余的行为和Serial收集器完全一致。适用于在多核环境下使用
在这里插入图片描述

Parallel Scavenge收集器:与ParNew收集器特性相同(复制算法、多线程),只是它的目标是达到一个可控制的吞吐量

吞吐量:CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间)+(垃圾收集时间)。高吞吐量则可以高效地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务

老年代收集器:
Serial Old收集器:使用标记-整理算法,老年代单线程收集器
Parallel Old收集器:Parallel Scavenge收集器的老年代版本(基于标记-整理算法、多线程),Parallel Scavenge+Parallel Old的组合才能真正达到“吞吐量优先”的目的
在这里插入图片描述

CMS收集器:是老年代收集器,通常搭配ParNew新生代收集器使用。Concurrent Mark Sweep,追求最短回收停顿时间的收集器。使用标记-清除算法(看名字就知道)。
它的整个过程分为四个步骤,包括:

  • 初始标记
    • 需要"Stop The World"
    • 标记一下GC Roots能直接关联的对象,速度很快
  • 并发标记
    • 从GC Roots的直接关联对象开始遍历整个对象图的过程
    • 整个过程耗时较长,但是不需要停顿用户线程
  • 重新标记
    • 需要"Stop The World"
    • 为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那部分对象的标记记录
    • 在这个阶段,CMS会利用增量更新机制来解决并发标记期间对象的引用变化
  • 并发清除
    • 清理被标记为死亡的对象
    • 由于不需要移动存活对象,所以可以与用户线程同时并发

总体上,CMS收集器的内存回收过程是与用户线程一起并发执行的。
它的优点:并发收集、低停顿
缺点也很明显:

  • 对处理器资源非常敏感。CMS默认启动的垃圾回收线程数量是(CPU核数+3)/4.在并发节点,虽然它不会导致用户线程停顿,但却因为占用一部分线程而导致应用程序变慢,降低总吞吐量。当处理器核心线程数不足四个时,CMS对用户程序的影响可能就变得很大
  • 无法处理“浮动垃圾”。由于并发标记和并发清理时用户线程还在运行,产生的新的垃圾对象在标记过程结束之后,那么CMS无法在当次收集中处理掉他们,只能留到下次收集时再清理,这部分垃圾被称为“浮动垃圾”。同时也由于垃圾收集时用户线程还在运行,需要预留一部分内存空间给程序使用,这会带来一个风险:要是CMS运行期间预留的内存无法满足程序分配新对象的需要,就会出现一次“并发失败”,这时候虚拟机将不得不启动后备预案:冻结用户程序的执行,临时启用Serial Old收集器来进行老年代的回收,但这样停顿时间就太大了
  • 标记-清除算法会导致大量空间碎片产生

参考:Minor GC vs Major GC vs Full GC

在这里插入图片描述
G1收集器:Garbage First开创了收集器面向局部收集的设计思路和基于Region的内存布局形式,是一款主要面向服务端应用的垃圾收集器。它有一个停顿预测模型,能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标。
G1仍是遵循分代收集理论设计的,但它的内存布局和其他收集器有明显差异:G1不再坚持固定固定大小以及固定数量的分代区域划分,而是把连续的java堆划分为多个大小相等的独立区域region,每个region都可以根据需要,扮演新生代的E区、S区,或者老年代空间。
Regio中还有一类特殊的Humongous区,专门用来存储大对象。G1认为只要大小超过了region一半的对象即可判定为大对象。对于那些超过了整个region的超级大对象,则会被存放在N个连续的Humongous region中,G1的大多数行为都把Humongous当作老年代来看待。
虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区域(不需要连续)的动态集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。
在这里插入图片描述
Region里面存在的跨Region引用对象如何解决?使用记忆集避免全堆作为GC Roots扫描,但在G1收集器上记忆集的应用其实要复杂很多,它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。G1的记忆集在存储结构的本质上是一种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更复杂,同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃圾收集器有着更高的内存占用负担。
回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与CMS中的“ConcurrentMode Failure”失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度,G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间“Stop TheWorld”。
G1收集器的运作过程大致可划分为以下四个步骤:

  • 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
  • 并发标记:从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理SATB记录下的在并发时有引用变动的对象
  • 最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
  • 筛选回收:负责更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
  • G1收集器除了并发标记外,其余阶段也是要完全暂停用户线程的,换言之,它并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才能担当起“全功能收集器”的重任与期望
    在这里插入图片描述
    当G1垃圾收集器垃圾产生的速度大于垃圾回收的速度时,G1会启动一次全局的暂停,即“stop the world”,以进行垃圾回收。这个过程会暂停所有的应用线程,以便垃圾收集器能够独占整个堆内存,进行彻底的垃圾回收工作。
    和CMS的区别
  • CMS收集器主要作为老年代的收集器,它可以配合新生代的Serial和ParNew收集器一起使用;G1收集器则覆盖了整个java堆,包括老年代和新生代,因此它不需要结合其他收集器使用。
  • 停顿时间(STW)
    • CMS收集器以最小化停顿时间为目标,其设计旨在减少垃圾收集导致的停顿时间,使得应用程序在执行垃圾收集时仍然能够响应客户端的
    • G1收集器则通过建立可预测的停顿时间模型来控制垃圾回收的停顿时间,这有助于避免应用雪崩现象(即系统长时间卡死)
      优点:
  • 与CMS的“标记-清除”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region之间)上看又是基于“标记-复制”算法实现,无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。
    缺点:
  • 比起CMS,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高。
    • 就内存占用来说,虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间;相比起来CMS的卡表就相当简单,只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要
    • 在执行负载的角度上,G1除了使用写后屏障来进行同样的(由于G1的卡表结构复杂,其实是更烦琐的)卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况
  • 目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间
内存分配与回收

对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,则优先在TLAB上分配

  • 大多数情况下,对象在新生代的Eden区分配,当Eden区没有足够的空间进行分配,虚拟机将发起Minor GC。Minor GC时如果存活对象无法全部放入Survivor区,将通过分配担保机制进入老年代
  • 大对象直接进入老年代。大对象指需要大量连续内存空间的Java对象,如那种很长的字符串以及数组。HotSpot虚拟机提供了-XX:PretenureSizeThreshold参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作。
  • 长期存活的对象将进入老年代。每个对象都有一个年龄计数器。每熬过一次Minor GC,年龄就增加1岁,当年龄增加到一定程度(默认15),将会晋升到老年代(由于是每熬过一次GC,年龄增加一岁,因此实际上是在第16次垃圾回收时,晋升到老年代)
  • 动态对象年龄判定:
    • ⚠️:如果Survivor区域内年龄1+年龄2+年龄3+年龄n的对象总和大于Survivor区的50%,此时年龄n及以上的对象会进入老年代,不一定要达到15岁
    • 旧的是错误的:如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代
      空间分配担保:在进行Minor GC之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果成立,那么Minor GC可以确保是安全的。如果不成立,则根据设置是否允许担保失败。如果允许,那么会检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC(如果MInor GC后出现担保失败,则再发起一次Full GC);如果小于或者设置不允许冒险,则改为进行一次Full GC
常用工具

jps:列出正在运行的虚拟机进程
在这里插入图片描述
jstat:用于收集虚拟机各方面的运行数据(如垃圾收集、类加载)
在这里插入图片描述
查看jvm中不同内存区域的内存占用
可以使用jstat -gc/gcutil pid
可以看到,S区和E区为1:8,2S+E与O近似为1:2
在这里插入图片描述
注意要查看的是机器内存,用 free -m。
如果要查看直接内存,有几种方法:
1.jcmd。使用jcmd需要在启动命令里加入监控参数,会带来5%到15%的性能损耗,不建议生产使用
2.分析堆转储(Heap Dump)
虽然堆转储主要关注JVM堆内存的内容,但在分析堆转储时,发现与直接内存相关的对象或数据结构。这有助于你了解哪些对象可能正在使用直接内存,并据此推断直接内存的占用情况。
在这里插入图片描述
3.使用外部监控工具VisualVM、JProfiler等

jinfo:显示虚拟机配置信息、各种虚拟机参数。jps -v只能展示显式设置给虚拟机的配置,隐性配置只能通过jinfo来查看
jstack:显示虚拟机的线程快照,可以分析死锁,或者根据线程id查找程序调用
在这里插入图片描述

jmap:主要是为了生成堆内存转储快照文件(headdump),同时还可以查询堆和方法区的详细信息
主要是 -dump、-heap
在这里插入图片描述

jhat:用于分析headdump,会建立一个http服务器,用户可在浏览器上查看分析结果,一般没人用

使用方法:
(1)先通过jps找到服务进程id(通过top命令看到的也是进程id,同一个)
(2)jstat -gc 进程id,查询服务的垃圾收集状况
(3)jmap -dump 进程id,生成Java堆转储快照
(4)jhat dump文件,分析dump文件
(5)jstack 进程id,生成线程快照,用于分析线程长时间停顿的原因,如死锁、死循环等等

虚拟机调优思路:
卡顿
(1)分析是不是产生了频繁的full gc
(2)产生full gc的原因是因为老年代里的对象生成的比较快
(3)分析是否是代码原因
内存溢出
(1)报错信息即可知

类文件结构

class文件是一组以字节为基础单位的二进制流。

  • 每个class的头四个字节被称为魔数,用于确定这个文件是否为一个能被虚拟机接受的class文件,值为固定的0xCAFEBABE。
  • 紧接着的是Class文件的版本号:第5、第6个字节是次版本号,第7第8个字节是主版本号。
  • 紧接着的是常量池的入口,常量池可以比喻为Class文件的资源仓库。比如常量池入口处第一个字节表示常量池的常量数量,紧接着就是第一个常量的类型,常量的属性,比如长度、标志、常量的内存地址等。
    常量池中主要存放两大类常量:字面量和符号引用
    字面量就是通常意义上的常量,比如文本字符串、被声明为final的常量值等
    而符号引用则属于编译原理方面的概念,主要包括:
    • 被模块导出或者开放的包(Package),应该就是包名
    • 类或接口的全限定名
    • 字段的名称和描述符,如public String
    • 方法的名称和描述符
    • 方法句柄和方法类型
  • 紧接着是访问标志,用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口?是否是一个枚举?是否是public?是否是abstract?如果是类,是否是final?等等
  • 紧接着是类索引、父类索引和接口索引。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,接口索引就用来描述这个类实现了哪些接口
  • 紧接着是字段表,用于描述接口或类中声明的变量/字段,如字段名称,访问权限,是否final,是否volatial,是否transient等等
  • 紧接着是方法表,包括方法名、返回类型、参数类型、访问修饰符、方法体等
  • 紧接着是属性表,class文件的属性表,如代码行号表、局部变量表、异常表等,这些属性提供了更多关于类或方法的元数据。

类加载机制

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

类加载的时机

类的生命周期:一个类型从被加载到虚拟机内存中开始到卸载出内存为止,它的整个生命周期将会经历 加载、验证、准备、解析、初始化、使用、卸载七个阶段,其中验证、准备、解析三个部分统称为连接
在这里插入图片描述

对于初始化阶段,虚拟机规范严格限定了有且只有六种情况必须立即对类进行“初始化”(而加载、验证、准备自然要在此之前开始):
1)遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是

  • 使用new关键字 实例化对象的时候
  • 读取或设置一个类的静态字段的时候(被final修饰、已在编译期把结果放入常 量池的静态字段除外)
  • 调用一个类的静态方法的时候
    2)使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化, 则需要先触发其初始化
    3)当初始化一个类时,但发现父类未初始化,则先要触发父类的初始化
    4)虚拟机启动,用户需要指定一个要执行的主类(包含main)方法的那个类)。虚拟机会先初始化这个主类
    5)当使用jdk1.7动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic,REF_putstatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化
    6)当一个接口中定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化
    除了以上六种场景的其他引用类型的方式都不会触发初始化,称为被动引用
  • 通过子类引用父类的静态字段,不会导致子类的初始化
    原因:根据上面的规则1)第二条,对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。简单说,就是静态字段属于谁,就初始化谁
public class Main  {

    public static  int i=0;
    static {
        System.out.println("main static");
    }
}
public class Test2 extends Main {
    public static  int i2=0;
    static {
        System.out.println("test2");
    }
}

public class Test  {
        public static void main(String[] args) {
           System.out.println(Test2.i);
        }
    }
 结果:
 main static 
 0
 
public class Test  {
        public static void main(String[] args) {
           System.out.println(Test2.i2);
        }
    }
结果:
main static
test2
0
这里是加载子类时,先加载父类
  • 通过数组定义来引用类,不会触发此类的初始化
public class TestMain {
    public static void main(String[] args) {
        Main[] ta = new Main[10];
    }
}
结果:
  • 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
    在编译期通过常量传播优化,已经将次常量的值“hello world”直接存储在Test类的常量池中,以后Test类对常量Main.HELLOWORLD的引用,实际都被转化为Test类对自身常量池的引用了。也就是说,实际上Test类的Class文件中并没有Main类的符号引用入口,这两个类在编译成Class文件之后就不存在任何联系了
public class Main  {

    public static final String HELLOWORLD="hello world";
    static {
        System.out.println("main static");
    }
}
public class Test  {


        public static void main(String[] args) {
            int c=Main.HELLOWORLD;
        }
    }
类加载的过程
加载

1)通过类的全限定名获取类的二进制流;
2)将二进制流转化为方法区的运行时数据结构;
3)在内存中生成一个代表这个类的Class对象,作为方法区这个类的各种数据的访问入口

加载阶段尚未完成,连接阶段可能已经开始

验证

确保Class文件中的字节流的信息符合虚拟机的要求

通过验证,字节流才会进入内存中的方法区进行存储。验证是在加载进行中就开始验证

准备

为类变量(即被static修饰的静态变量)分配内存并设置初始值的阶段

  • 在逻辑上,此处分配内存是在方法区上;在物理上,可能是在堆上分配

比如代码 private static OtherMyclass staticVar = new OtherMyclass()
在准备阶段,staticVar 会被赋予一个默认值 null,因为它是一个对象引用。
在初始化阶段,staticVar 会被赋予一个新的 OtherMyclass 实例的值。这个 OtherMyclass 实例是在堆上分配的。
也就是说,new OtherMyclass() 会在堆上创建一个新的 OtherMyclass 对象,并且这个对象的引用被赋值给 staticVar。因此,staticVar 指向的是堆内存中的一个对象。

  • 准备阶段的内存分配只包括类变量,而不包括实例变量,实例变量将会在对象实例化后时随着对象一起分配在堆
  • 对于被final修饰的静态变量,准备阶段变量值会被直接初始化为真实值
解析

将常量池内的符号引用替换为直接引用的过程

  • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。在Java中,一个java类将会编译成一个class文件。在编译时,java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language(假设是这个,当然实际中是由类似于CONSTANT_Class_info的常量来表示的)来表示Language类的地址
  • 直接引用:可以是直接指向目标的指针(比如,指向“类型”【Class对象】、类变量、类方法的直接引用可能是指向方法区的指针)、相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量,通过偏移量虚拟机可以直接在该类的内存区域中找到方法字节码的起始位置)一个能间接定位到目标的句柄。直接引用是和虚拟机的布局相关的,如果有了直接引用,那引用的目标必定已经在虚拟机内存中存在
初始化

初始化阶段是执行类构造器初始化()方法的过程:
类的初始化方法(),并不是程序员在java代码中直接编写的方法,它是编译器自动生成的产物,它是这样产生的:

  • 类中的所有类变量的赋值动作(和上面的staticVar 对应上了)和静态语句块(即static{})中的语句合并产生。编译后的顺序是根据源文件中语句的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的静态变量能赋值,但不能访问
  • 子类的初始化方法()执行之前,父类的初始化方法()必定执行完毕
  • 如果类或接口没有静态变量,也没有static语句块,那么就没有初始化方法()
  • 接口中不能使用static语句块,但仍然有静态变量赋值操作,执行接口的初始化方法不需要先执行父接口的初始化方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的初始化方法,使用才会触发初始化

类加载器

比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义
在这里插入图片描述

双亲委派模型(Parents Delegation Model)

– 双亲的翻译,会误导人是有两个父加载器,其实不一定是两个,通常是一个
如果一个类加载起收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载起去完成,每一个层次的类加载起都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载起中,只有父类加载起反馈自己无法完成这个加载请求时,子类加载起才会尝试自己去加载

java中新建一个类加载器时,可以传入一个类加载起作为参数,即称为它的父类加载器,这样可以保证子类加载器中使用的同名类和父类加载器的同名类是equal的

破坏双亲委派模型
  • 第一次破坏
    由于双亲委派模型是在JDK1.2之后才被引入的,而类加载器和抽象类java.lang.ClassLoader则在JDK1.0时代就已经存在,面对已经存在的用户自定义类加载器的实现代码,Java设计者引入双亲委派模型时不得不做出一些妥协。在JDK1.2之后的ClassLoader中添加了一个新方法findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在loadClass()中编写代码(应该是因为1.2之前的一些类加载器的loadClass()方法没有遵循双亲委派模型,所以通过这种方式规避它)。在loadClass()方法的逻辑里,如果父类加载失败,则会调用自己的findClass()方法来完成加载,这样就可以保证新写出来的类加载器是复合双亲委派模型的
  • 第二次破坏
    双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷所导致的,双亲委派很好地解决了各个类加载器的基础类的同一问题(越基础的类由越上层的加载器进行加载)。如果基础类又要调用回用户的代码,那该么办?
    典型的比如JNDI、JDBC
    为了解决这个问题,Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置,如果创建线程时还未设置,他将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。
    有了线程上下文加载器,JNDI服务就可以使用它去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上就是打通了双亲委派模型层次结构来逆向使用类加载器,实际上已经违背了双亲委派模型的一般性原则,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI、JDBC、JCE、JAXB和JBI等。
    参考:双亲委派模型的破坏者-线程上下文类加载器
  • 第三次破坏
    双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求导致的,这里所说的“动态性”指的是当前一些非常“热门”的名词:代码热替换、模块热部署等
    当受到类加载请求时,OSGi将按照下面的顺序进行类搜索:
    1)将java.*开头的类委派给父类加载器加载。
    2)否则,将委派列表名单内的类委派给父类加载器加载。
    3)否则,将Import列表中的类委派给Export这个类的Bundle的类加载器加载。
    4)否则,查找当前Bundle的ClassPath,使用自己的类加载器加载。
    5)否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载。
    6)否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
    7)否则,类加载器失败。
    上面的查找顺序只有开头亮点仍然符合双亲委派模型的原则,其余的类查找都是在平级的类加载器中进行的

虚拟机字节码执行

运行时栈桢结构

在这里插入图片描述

栈桢是虚拟机运行时数据区中的虚拟机栈的栈元素,是用于支持虚拟机进行方法调用和方法执行的数据结构。
栈桢存储了方法的局部变量表、操作数栈、动态链接、方法返回地址等信息。每一个方法从调用到执行完成,都对应一个栈桢在虚拟机栈里从入栈到出栈的过程
编译代码时,栈桢中需要多大的局部变量表,多深的操作数栈都已经完全确定,并写入到方法表的Code属性中,因此一个栈桢需要的内存不会收到程序运行期间变量数据的影响,只取决于具体的虚拟机实现
局部变量表:如果执行的是实例方法,那局部变量表的第0位默认传递方法所属对象实例的引用,在方法中通过this来访问这个隐含的参数

局部变量定义了必须赋初始值,不然无法使用

操作数栈:例如加法操作即是将操作数栈中栈顶的两个元素出栈并相加,然后将结果入栈
动态连接:在运行期将符号引用转化为直接引用。ps:在类加载的时候将符号引用转化为直接引用,被称为静态解析

分派

分派中的类型:静态类型、实际类型
静态分派——重载:选择的是静态类型

  • 静态分派发生在编译期。一个典型的例子是方法重载,即在同一个类中定义了多个同名方法,但参数类型或个数不同。在编译期间,编译器可以明确调用哪个方法。

动态分派——重写:选择的是实际类型

  • 动态分派发生在运行期。最常见的是,Father object=new Son();调用object.func()时,实际调用的是Son的func方法。

编译

编译大致可以分为3哥过程,分别是:
1⃣️解析与填充符号表过程
2⃣️插入式注解处理器的注解处理过程
3⃣️语义分析与字节码生成过程
解析与填充符号表
1⃣️词法分析、语法分析
词法分析是将源代码的字符流转变为token集合
语法分析是根据token序列构造抽象语法树的过程
语义分析
语法糖的解析属于语义分析
泛型与类型擦除
java中的泛型是在编译时实现的,而不是在运行时。只在程序源码中存在,在编译后的字节码中,就已经替换为原来的原生类型(裸类型)了,并且在相应的地方插入了强制类型转换代码

如Map<String,String> map=new HashMap<String,String>();
map.put(“hello”,“你好”);
map.get(“hello”)
编译成class文件后,再反编译,会发现泛型类型都变为了原生类型
Map map=new HashMap();
map.put(“hello”,“你好”);
(String)map.get(“hello”);

java内存模型与线程

由于计算机的存储设备与cpu的运算速度有几个数量级的差距,所以现代计算机加入一层读写速度尽可能接近处理器运行速度的高速缓存cache来作为缓冲;将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存中,这样处理器就无须等待缓慢的内存读写了
缓存一致性
在多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一主内存。当多个处理器的运算任务涉及同一块主内存区域时,确保各个缓存中的数据保持一致性的机制。
具体来说,当某个设备或处理器修改了内存中的数据时,它会通过某种方式(比如信号量或更新日志)来通知其他设备或处理器。这样其他设备或处理器,直接从内存中加载最新数据,而不是过时的数据。

处理器、高速缓存、主内存的交互关系

在这里插入图片描述

这里的主内存比如内存条
TPS(Transactions Per Second)每秒事务处理数,代表一秒内服务端平均能响应的请求总数,是衡量服务器的性能的重要指标之一

Java内存模型

在这里插入图片描述
Java内存模型规定所有的变量都存储在主内存(这里的主内存与介绍物理硬件的主内存可类比,但是这里的主内存物理上仅是虚拟机内存的一部分)。每条线程还有自己的工作内存(可与处理器高速缓存类比),线程的工作内存中保存了所使用的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成

这里的主内存是虚拟机内存,这里的主内存和工作内存与Java内存区域中的Java堆、栈、方法区等不适同一个层次对内存的划分,这两者基本是没有任何关系的。如果两个一定要勉强对应,那么可以这样理解。主内存主要对应于堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域
局部变量与方法参数是线程私有,不涉及上述模型
java内存模型是建立在计算机内存模型的基础上的。JVM作为java程序的执行环境,需要将java内存模型中定义的操作映射到具体的硬件层面的内存操作。这意味着,java内存模型通过一些规则和约束来保证多线程程序的正确执行,而这些规则和约束的实现则依赖于底层硬件提供的内存访问机制。

线程的实现

操作系统内核线程的操作,如创建、析构、同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态和内核态来回切换。
Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或者唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到核心态,状态转换需要耗费很多的处理器时间

锁优化

重量级锁:
线程状态的切换,需要操作系统来帮忙完成,涉及到用户态到核心态的切换,进行这些切换需要很多处理器时间,是重量级的操作。例如syncronized。

ReentrantLock与syncronized相比,多了一些高级功能,主要有以下三项:

  • 等待可中断:指等待线程请求锁的时候,可以选择等待一段时间,如果还没获取到锁,可以去做其他事情
  • 公平锁
  • 绑定多个条件

自旋锁与自适应自旋
如果物理机器有一个以上的处理器,能让两个或以上的线程同时执行,我们就可以让后面请求锁的线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否会很快释放锁。为了让线程等待,我们让线程执行一个忙循环(自旋即不断循环检查锁是否可用),这就是所谓的自旋锁
自旋等待不能代替阻塞,自旋等待虽然避免了线程切换的开销,但它要占用处理器时间
适用场景:①锁持有时间较短 ②并发度不是特别高
自适应自旋锁:自旋时间由前一次在同一个锁上的自旋时间及锁的拥有者状态来决定当前线程的自旋时间。

具体来说,如果线程在前一次自旋中成功获取到了锁,并且当前锁的拥有者线程正在运行中(这通常意味着锁可能会很快被释放),那么自适应自旋锁会倾向于认为这次自旋也有很大可能性成功,因此它会允许线程进行更长时间的自旋等待。这样,线程就有更多机会在不进入阻塞状态的情况下获得锁,从而提高了CPU的使用效率。

反之,如果线程在之前的自旋中很少成功获取到锁,那么自适应自旋锁会相应减少自旋的时间,甚至可能会使线程直接进入阻塞状态,以避免无效的CPU消耗。这种策略有助于避免在锁竞争激烈或锁持有时间较长的情况下,线程长时间自旋而浪费处理器资源

适用场景:①锁持有时间较短 ②锁竞争可以稍微比自旋锁激烈。但是,如果锁持有时间过长或者锁竞争过于激烈,自适应自旋锁可能仍然会导致CPU资源的浪费。它只能一定程度上提高自旋锁的并发性能。
锁消除
是一种编译器或运行时系统优化技术,用于消除不必要的同步操作。虚拟机即时编译器在运行时,对一些代码上要求同步,但是检测到不可能存在共享数据竞争的锁进行消除。例如,当一个锁只在单线程中使用,或者一个共享变量在程序中只读取不修改时,就可以消除对该锁的使用。
锁粗化
如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部
轻量级锁
它的“轻量级”是相对于传统使用monitor锁而言的。它适用于存在多线程竞争,但任意时刻最多只允许一个线程竞争获得锁的场景,也就是说,锁竞争不会太过激烈(至于锁的持有时间,不在意),线程在这种情况下也不会发生阻塞,也有避免了系统调用的开销。轻量级锁的设计初衷是在没有多线程竞争的前提下,减少传统重量级锁使用操作系统互斥量产生的性能消耗。它通过CAS操作和自旋等待来尝试获取锁,从而避免了线程挂起和唤醒的开销。

轻量级锁的核心原理是基于CAS+自旋操作与对象头Mark Word和线程栈帧中的锁记录的状态来实现的一种高效同步机制。首先,当线程尝试获取锁时,JVM会检查对象头Mark Word中的锁状态。如果锁未被其他线程持有(即处于无锁状态),当前线程就会尝试使用CAS操作将对象头Mark Word中的状态设置为指向自己线程栈帧中的锁记录,从而获取轻量级锁。如果CAS操作成功,那么当前线程就成功获得了轻量级锁,可以继续执行临界区的代码。
如果CAS操作失败,说明有其他线程已经持有轻量级锁,那么当前线程就会进入自旋状态,不断尝试获取锁,直到成功为止。

轻量级锁在以下情况会升级为重量级锁:
1.自旋次数超过阈值
自旋的目的是为了避免线程切换的开销,但如果自旋次数过多,说明锁竞争激烈,此时升级为重量级锁是更合理的选择。
2.锁竞争激烈
如果同一时间有太多的线程尝试获取同一个对象的锁,导致系统消耗大量的CPU资源在自旋上,那么轻量级锁也可能升级为重量级锁。这是为了避免过多的线程长时间自旋而浪费计算资源。

偏向锁
消除数据在无竞争情况下的同步原语,偏向锁会偏向于第一个获取它的线程,如果在接下来的执行过程中该锁没有被其他线程获取,则持有偏向锁的线程永远不需要再进行同步

偏向锁的核心原理在于,当线程再次进入同步块时,只需要判断一下当前线程ID与偏向锁的线程ID是否一致,如果一致,则可以直接进入同步块执行,无需再进行锁的申请与释放操作。这大大提升了无锁竞争时的程序性能。
偏向锁主要适用于只有一个线程频繁访问同步块的场景。在这种场景下,偏向锁可以显著提高程序的执行效率,因为避免了频繁地申请和释放锁的开销。
重量级锁
重量级锁的开销相对较大,因为它涉及到线程的挂起和唤醒操作,这些操作需要操作系统的参与,因此会有较大的性能损耗。然而,在多线程竞争环境下访问锁,且执行临界区的时间比较长时,重量级锁是一种有效的解决方案,它能确保线程安全地访问共享资源。
其他的锁优化策略
1.锁分离,例如将锁划分为读写锁,适用于读多写少的场景

锁升级的过程
锁升级的过程涉及从较细粒度的锁到较粗粒度锁的转换,以减少系统开销并提高并发性能。以下是锁升级的详细过程:
1.无锁状态
程序初始时,没有锁竞争,因此对象处于无锁状态。
2.偏向锁

  • 当对象第一次被线程获取时,JVM会设置对象头的标志位为偏向模式(01)。
  • 使用CAS操作将获取到锁的线程ID记录到对象的Mark Word中。
  • 如果CAS操作成功,持有偏向锁的线程在下次进入同步块时,无需再次进行同步操作。
  • 当有另一个线程尝试获取这个锁时,偏向锁结束。

3.轻量级锁

  • 当偏向锁失效或存在多个线程竞争锁时,锁会升级为轻量级锁。
  • JVM在对象的对象头设置一个指向线程栈中锁记录的指针,并将对象头的Mark Word复制到线程栈的锁记录中。
  • 如果多个线程竞争同一个轻量级锁,锁会升级为重量级锁。在升级过程中,JVM会使用自旋锁机制,让线程忙等待直到获取到锁。如果自旋等待超时或线程被中断,轻量级锁会升级为重量级锁。

4.重量级锁

  • 当锁升级为重量级锁时,JVM在操作系统层面使用互斥量实现锁的控制。
  • 此时,每次线程获取锁都会进入阻塞状态,直到获取到锁为止。

偏向锁:仅有一个线程进入临界区
轻量级锁:多个线程交替进入临界区
重量级锁:多个线程同时进入临界区

参考下

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
要实现一个JVM虚拟,需要深入了解JVM的内部实现原理和Java语言规范。一般来说,JVM虚拟由以下几个模块组成: 1. 类加载器:负责从文件系统、网络或其他来源加载Java类文件,并将其转换为JVM能够理解的格式。 2. 运行时数据区:Java程序运行时需要的内存空间,包括Java堆、方法区、虚拟栈、本地方法栈、程序计数器等。 3. 执行引擎:负责执行Java字节码,将它们转换为器码并执行。 4. 垃圾收集器:负责回收未使用的对象,释放内存空间。 5. 本地方法接口:允许Java代码调用本地方法(C/C++代码)。 下面是一个简单的Java虚拟实现的示例: ```java public class JVM { private ClassLoader classLoader; private RuntimeDataArea runtimeDataArea; private ExecutionEngine executionEngine; private GarbageCollector garbageCollector; private NativeMethodInterface nativeMethodInterface; public JVM() { classLoader = new ClassLoader(); runtimeDataArea = new RuntimeDataArea(); executionEngine = new ExecutionEngine(); garbageCollector = new GarbageCollector(); nativeMethodInterface = new NativeMethodInterface(); } public void run(String className) { // 加载类 Class clazz = classLoader.loadClass(className); // 初始化类 clazz.initialize(runtimeDataArea); // 执行方法 Method mainMethod = clazz.getMethod("main", String[].class); executionEngine.execute(mainMethod); } } ``` 这个简单的JVM实现只包含了类加载器、运行时数据区和执行引擎三个部分。在实现时,还需要考虑Java语言规范中的各种细节,如异常处理、线程安全等。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值