JVM源码
2024-09-03 10:42:03 11 举报
AI智能生成
JVM源码是Java虚拟机的实现,它允许Java程序在所有平台上运行。Java源码文件通常以.java为后缀,通过javac编译器将其转换为.class文件,然后在JVM上运行。JVM源码主要包括类加载器、执行引擎、垃圾回收器等模块。类加载器负责加载和链接class文件,执行引擎负责执行字节码,垃圾回收器负责回收无用的对象,释放内存。通过深入研究JVM源码,可以更好地理解Java程序的运行原理和调优方法。
作者其他创作
大纲/内容
为什么HotSpot将BootstrapClassLoader使用C++语言编写而ExtClassLoader和AppClassLoader用java语言编写
HotSpot JVM将BootstrapClassLoader用C++语言比那些,而ExtClassLoader和AppClassLoader用Java语言编写,主要是处于以下几个原因:<br> 1.引导类加载器(BootstrapClassLoader)<br> 核心系统组件:BootstrapClassLoader是JVM的核心部分,负责加载Java核心库(如java.lang.*、java.util.*等)。这些核心库在JVM启动时必须<br> 被加载,并且它们的加载过程必须在任何Java代码执行之前完成。<br> 依赖关系:由于BootstrapClassLoader加载的是最基础的Java类库,它不能已离开于任何Java嘞,否则,会陷入加载循环(依赖的类还未加载,需要先加载依赖类)<br> 性能和控制:使用C++编写可以更直接地控制内存和资源,提高性能和启动速度<br> 2.扩展类加载器(ExtClassLoader)和应用类加载器(AppClassLoader)<br> 实现简便:ExtClassLoader和AppClassLoader主要是加载扩展库和应用程序嘞,它们不需要像BootstrapClassLoader那样处理JVM启动的核心部分。因此可以用<br> Java语言编写,利用java自身的特性,使得代码更简洁和易维护<br> 继承和扩展:用Java编写可以更方便地利用Java的面向对象特性,继承和扩展ClassLoader嘞,从而更容易地实现自定义类加载器。<br> 运行时环境:在JVM启动之后,ExtClassLoader和AppClassLoader运行已经初始化的Java环境中,不需要担心加载器本身的类是否已经加载<br> <br> 总结:<br> BootStrapClassLoader使用C++编写,确保JVM在启动时能够加载最核心的类库,并且不依赖于任何Java类,避免循环依赖,同事提高性能和控制性<br> ExtClassLoader和AppClassLoader使用Java语言编写,方便实现和扩展,同时在JVM启动后利用现有的Java环境和面向对象特性,更易于维护和扩展。<br> <br> 这种设计确保了JVM的启动和运行时类加载机制既高效又灵活,能够满足不同阶段的需求
Tomcat为什么要打破双亲委派机制
我们知道,Java默认的类加载机制是通过双亲委派模型来实现的,而Tomcat实现的方式又和双亲委派模型有所区别。原因在于一个Tomcat容器允许同时运行多个Web程序,每个Web程序依赖的类又必须是相互隔离的。因此,如果Tomcat使用双亲委派模式来加载类的话,将导致Web程序依赖的类变为共享的。<br>举个例子,假如我们有两个Web程序,一个依赖A库的1.0版本,另一个依赖A库的2.0版本,它们都使用了类xxx,其实现的逻辑因类库版本的不同而结构完全不同。那么这两个Web程序的其中一个必然因为加载的Class不是所使用的Class而出现问题!而这对于开发来说是非常致命的<br><br>完整的的Tomcat类加载图。我们在这张图中看到很多类加载器,除了JDK自带的类加载器,我们尤其关心Tomcat自身持有的类加载器。仔细一点我们很容易发现:Catalina类加载器和Shared类加载器,它们并不是父子关系。为啥这样设计,我们得分析一下每个类加载的用途,才能知晓。<br>1.Common类加载器,负责加载Tomcat和Web应用都服用哦个的累<br>1.1 Catalina类加载器,负责加载Tomcat专用的类,而这些被加载的类在Web应用中将不可见<br>1.2 Shared类加载器,负责加载Tomcat下所有的Web应用程序都复用的类,而这些被加载的类在Tomcat中将不可见<br>1.2.1 WebApp类加载器,负责加载具体的某个Web应用程序所使用到的类,而这些被加载的类在Tomcat和其他的Web应用程序都将不可见(每一个Web应用程序对应一个WebApp类加载器)<br>1.2.2 Jsp类加载器,每个jsp页面一个类加载器,不同的jsp页面有不同的类加载器,方便实现jsp页面的热拔插
为什么JVM在给对象分配内存时必须要求是一块连续的内存,不可以是散乱的内存吗?
JVM在给对象分配内存时要求时一块连续的内存,主要有以下几个原因:<br>1.性能优化:连续内存可以更好地利用CPU的缓存,提高访问速度。因为CPU的缓存是以缓存行(cache line)为单位存储数据的,连续的内存可以使一个缓存行中存储更多的有用数据,减少缓存失效(cache miss)的概率,从而提高程序的执行效率<br>2.简化内存管理:使用连续的内存块可以简化内存管理,特别是在垃圾回收(Garbage Collection,GC)时,如果对象分布在不连续的内存中,垃圾回收器在回收和整理时会更加复杂和低效。连续内存使得标记-清楚和压缩算法更容易实现和优化。<br>3.对象访问的便利性:在Java中,对象引用实际上是一个指针,如果对象存储在连续的内存中,通过指针偏移可以快速地访问对象的字段。这种方式比起遍历不连续的内存块要高效得多<br>4.堆的结构设计:JVM的堆内存通常被设计为一个大的连续内存区域,这样可以有效地进行内存分配和回收。分配连续的内存块符合堆的设计原则,有助于维护堆的结构和性能。<br><br>尽管理论上可以将对象分配到不连续的内存中,但这样做会引入大量的复杂性,并且带来性能上的损失。因此,JVM选择了在大多数情况分配连续内存的策略,以确保系统的高效和稳定运行。
JVM为什么不直接从局部变量表中读取数据,而是压入操作数栈
1.因为操作数栈作为缓冲地带,可以将运算结果的中间结果保存到操作数栈<br>2.另外操作数栈是一个顺序的高速寄存器,可以更加快速的操作数据
Java虚拟机(JVM)的设计是基于栈的架构,其中操作数栈(Operand Stack)和局部变量表(Local Variable Table)是执行字节码指令的两个关键组件。以下是一些原因,解释了为什么JVM在执行过程中不直接从局部变量表读取数据,而是先将数据压入操作数栈:<br>1.统一操作模型:操作数栈提供了一个统一的操作模型,所有的计算都是通过栈来进行的。这种设计简化了指令集和执行引擎的实现,使得指令集更加紧凑和易于理解<br>2.指令集简化:如果直接从局部变量表读取数据,那么指令集将需要包含更多的操作来直接引用局部变量表的特定位置。而通过操作数栈,可以使用较少的指令来完成复杂的操作。<br>3.灵活性:操作数栈允许更灵活的操作顺序和数据流控制。例如,方法调用的参数传递、返回值处理以及控制流指令(如条件分支和循环)都可以通过操作数栈方便地实现。<br>4.动态作用域:操作数栈支持动态作用域,即在运行时可以动态地改变变量的作用域。这是因为在执行过程中,操作数栈的内容是动态变化的<br>5.优化编译器设计:通过使用操作数栈,编译器的设计可以更加简单,因为它只需要关注如何生成将数据推送到栈上和从栈上弹出数据的指令序列<br>6.支持多态和动态绑定:Java语言支持堕胎,这意味着方法调用可能在运行时解析。操作数栈可以更容易地支持这种机制,因为它允许在调用方法之前将对象引用和参数推送到栈上,然后在运行时确定调用哪个方法实现<br>7.字节码间接性:操作数栈的使用使得字节码更加简洁。例如,iload指令将一个int类型的局部变量加载到操作数栈上,后续的算术指令可以直接从栈上取操作数<br><br>总之,JVM的设计选择是基于操作数栈来执行字节码是为了实现一个简单、高效且灵活的执行模型。尽管这种设计可能不是最快的方式(与基于寄存器的架构相比),但它为Java语言的特性提供了良好的支持,并且使得JVM的实现更为统一和可移植
指令集简化例子<br>假设我们有一个Java方法,它包含一个局部变量int a和一个简单的操作,即将a的值加1,在使用操作数栈的情况下,对应的字节码可能如下:<br>```bash<br>iload_0 // 将局部变量表种的索引为0的int值加载到操作数栈上<br>iconst_1 // 将常量1推送到操作数栈上<br>iadd // 从操作数栈上弹出两个int值相加,并将结果推回操作数栈<br>istore_0 // 将操作数栈顶的int值存储回局部变量表索引为0的位置<br>```<br>这里的iload_0和istore_0指令非常简洁,因为他们不需要指定具体的局部变量名,只需要指定局部变量表种的索引。<br>现在,假设我们直接从局部变量表读取数据,指令集可能需要变得更加复杂。例如,我们可能需要以下指令:<br>```bash<br>load_var 0,a // 假设的指令,将局部变量a的值加载到某个寄存器<br>const 1 // 将常量1加载到另一个寄存器<br>add var a // 假设的指令,将寄存器中的值加到局部变量a上<br>```<br>在这个假设的指令集中,load_var和add var指令都需要额外的信息来指定局部变量表中的变量名(在这个例子中是a)。这意味着指令集必须包含对变量名的解析,这将导致指令集变得更加复杂,因为每个变量名都需要在编译时被解析并编码到指令中。<br>相比之下,使用操作数栈的指令集简化了这一过程,因为指令不需要知道变量名,只需要知道变量在局部变量表中的索引,这样,指令集可以设计得更加紧凑,每个指令可以更专注于执行特定的操作,而不是处理复杂的变量引用
为什么i++不是直接+1,而是用inc字节码,将它的step设置为1
因为设计师考虑到了还有存在i--的情况,如果直接将i++设置为+1的话,那么i--操作也需要设置一个专门的字节码指令来表示-1,但是如果有设置step的动作,那么在进行i--时,可以将step设置为-1,这是一种设计思想
Windows下类似Linux下的grep命令
java -XX:+PrintFlagsFinal -version | findstr Thread
Java中的handle模型是什么
在Java虚拟机(JVM)中,Handle模型是一种用于访问对象和执行方法调用的机制,它主要涉及到以下概念:<br>句柄(Handle): 句柄是一个用于间接定位对象的指针。在JVM中,每个对象都有一个句柄,这个句柄包含了对象在内存中的实际地址和其他元数据信息。使用句柄的好处是,当对象在移动时(例如垃圾回收时的压缩),只需要更新句柄中的地址,而不需要更新所有引用该对象的指针<br>以下是Handle模型的主要组成部分和功能:<br>1.句柄池(Handle Pool):JVM维护一个句柄池,其中存储了所有对象的句柄。句柄池中的每个句柄都对应一个具体的对象<br>2.句柄表(Handle Table):句柄表是一个数据结构,用于管理所有句柄的分配和释放,当一个对象被创建时,JVM会在句柄表中为它分配一个句柄,并在对象被销毁时释放该句柄<br>3.间接访问:通过句柄访问对象是间接的。这意味着当程序需要访问一个对象时,它首先获取对象的句柄,然后通过句柄表来访问对象的字段和方法<br>4.安全性:句柄模型提供了一定程度的安全性,因为它可以防止直接访问对象的内存地址,从而避免了潜在的而已操作<br><br>Handle模型与直接指针模型(Direct Pointer Model)相对,后者直接存储对象的内存地址,而不是句柄。直接指针模型在访问对象时更快,因为它省去了通过句柄间接定位对象的步骤,但缺点是在对象移动时需要更新所有指向该对象的指针。<br>在HotSpot中,使用那种模型(句柄模型或直接指针模型)可以通过JVM启动参数来配置,然而,现代JVM实现通常倾向于使用直接指针模型,因为它们通过优化垃圾回收算法来减少对象移动,从而使得直接指针模型在性能上更有优势。<br><br>总的来说,Handle模型是JVM用于管理和访问对象的一种机制,它通过句柄来间接引用对象,提供了对象移动时的灵活性,但可能会引入额外的性能开销<br>
操作系统的栈的槽位是多少字节
CPU总是以Word size为单位从内存中读取数据,在64位处理器中的word size为8个字节。64位的内存每次只能吞吐8个字节
并行与并发的区别
并行和并发是两个经常被提及的概念,尤其在讨论多线程编程、分布式系统、数据处理等领域时。尽管它们都与同时处理多个任务有关,但它们的含义和侧重点有所不同。<br>并行(Parallelism)<br>1.定义:并行是指同一时刻,有多条指令在多个处理器上同时执行<br>2.核心:并行关注的是资源的充分利用,特别是在多核或多处理器系统中,通过并行执行来提高效率<br>3.实现:并行通常通过将任务分解成多个子任务来实现,这些子任务可以同时执行,例如使用多线程或多进程<br>4.示例:在四核处理器上同时运行四个线程,每个线程运行在独立的核上<br>并发(Concurrency)<br>1.定义:并发是指同一时间段内,多个任务交替执行,使得宏观上看起来像是同时进行的<br>2.核心:并发关注的是结构设计,它允许处理多个任务,但不一定意味着这些任务同时执行<br>3.实现:并发可以通过时间分片(time slicing)、多线程、异步编程等技术来实现,使得单个处理器可以处理多个任务<br>4.示例:单核处理器上通过时间分片技术交替执行多个线程<br><br>主要区别:<br>1.执行方式:并行是真正的同时执行,而并发是交替执行,给人同时执行的错觉<br>2.处理器数量:并行通常需要多个处理器,而并发可以在单个处理器上事项<br>3.性能提升:并行可以显著提升性能,因为任务被分割并在多个处理器上同时执行。并发则更多关注于资源的有效管理,并不一定能提高性能,但可以提高资源的利用率<br>4.应用场景:并行适用于可以分解为多个独立子任务的问题,并发适用于需要处理多个任务但不需要同时执行的场景。<br><br>在软件工程中,并发和并行都是重要的概念。并发性是系统设计的一部分,它使得系统可以处理多个任务,而并行性则是优化手段,用于提高系统的性能。有时候,系统设计需要同时考虑并行和并发,以实现高效的任务处理<br>
编译OpenJDK
自己写的博客https://blog.csdn.net/Cover_sky/article/details/140477010
```bash<br>find ./ -name GenerateCurrencyData.java<br>./jdk/make/src/classes/build/tools/generatecurrencydata/GenerateCurrencyData.java<br>```
JDK相关工具
Hotspot Debugger(HSDB):JDK原生自带<br>以Windows系统为例,jdk8的环境,在jdk的lib目录下,启动之前,你需要确保你进入的lib目录和你当前的JAVA_HOME配置的JDK是相同的,否则可能会出现无法加载libarary的异常,进而无法使用HSDB,命令如下<br>```bash<br>java -cp .\sa-jdi.jar sun.jvm.hotspot.HSDB<br>```<br>调节字体大小的方法,添加环境变量JAVA_TOOL_OPTIONS<br>```bash<br>-Dswing.plaf.metal.controlFont=Dialog-22 -Dswing.plaf.metal.systemFont=Dialog-22 -Dswing.plaf.metal.userFont=SansSerif-22<br>```
这里需要用attach到一个java进程,
利用jps可以查看到相关指令
将进程号输入进去,我这里换了一个程序,进程号不同
会看到运行的Java Thread
查看main线程的调用栈
1.深入理解类加载机制
Klass模型<br>Java的每个类,在JVM中都有一个对应的Klass类实例与之对应,存储类的元信息如:常量池、属性信息、方法信息....从继承关系上也能看出来,类的元信息是存储在元空间的。<font color="#a23735">普通的Java类在JVM中对应的是InstanceKlass(C++)类的实例</font>,再来说下它的三个子类:<br>1.<font color="#a23735">InstanceMirrorKlass:用于表示java.lang.Class,Java代码中获取到的Class对象,实际上就是这个C++类的实例,存储在堆区,学名镜相类</font><br>2.InstanceRefKlass:用于表示java/lang/ref/Reference类的子类<br>3.InstanceClassLoaderKlass:用于遍历某个加载器的类<br><br>Java中的数组不是静态数据类型,而是动态数据类型,即是运行期生成的,Java数组的元信息用ArrayKlass的子类来表示:<br>1.TypeArrayKlass:用于表示基本类型的数组<br>2.ObjArrayKlass:用于表示引用类型的数组<br><br>总结:<br>非数组:<br><font color="#a23735">InstanceKlass -> 普通的类在JVM中对应的C++类 方法区<br>InstanceMirrorKlass -> 对应的是Class对象 镜像类 堆区</font><br>数组:<br>基本类型数组<br>boolean、byte、char、short、int、float、long、double -> TypeArrayKlass<br>引用类型数组: ObjArrayKlass<br><br>为什么还要有镜像类?<br>是为了安全,由JVM控制可以将哪些参数返回给用户
实操:<br>```java<br>public class Hello {<br><br> public static void main(String[] args) {<br> int[] a = new int[] {1,2,3};<br> Hello[] hello = new Hello[2];<br> Hello h = new Hello();<br><br> while(true);<br> }<br>}<br>```<br>利用HSDB查看main线程的调用栈,由于栈的规则是先进后出,也就是说意味着,当前方法栈的栈底存放的是当前方法的参数args,其次是int数组,Hello对象数组,我们可以查看它们的内存地址中都包含了哪些内容
基本数据类型的klass模型,还可以看到数组的内容
引用类型数组的klass模型,我们在代码中创建的Hello数组对象引用都是空的<br>
引用类型的klass模型
也就是_java_mirror,这里c++上的注解也是说明了这个InstanceMirroKlass的存在
类加载的过程<br>类的加载由7个步骤完成,如图所示。类的加载说的是前5个阶段。
加载<br>1.通过类的全限定名获取存储该类的class文件(没有指明必须从哪获取)<br>2.解析成运行时数据,即instanceKlass实例,存放在方法去<br>3.在堆区生成该类的Class对象,即instanceMirrorKlass实例<br><br>程序随便你怎么写,随便你用什么语言,只要能达到这个效果即可。就是说你可以改写openjdk源码,你写的程序能达到这三个效果即可。<br>预加载:包装类、String、Thread<br>因为没有指明必须从哪获取class文件,脑洞大开的工程师们开发了这些:<br>1.从压缩包中读取。如jar、war<br>2.从网络中获取,如Web Applet<br>3.动态生成,如动态代理、CGLIB<br>4.由其他文件生成,如JSP<br>5.从数据库读取<br>6.从加密文件中读取
验证<br>1.文件格式验证。如验证class文件中是否包含魔数(CAFE BABE)、主次版本号是否在当前虚拟机处理范围之内<br>2.元数据验证。如这个类是否有父类、这个类的父类是否继承了不允许被继承的类(如被final修饰的类)<br>3.字节码验证。整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析确定程序语义是合法的,如保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似这样的情况:在操作数栈放置了一个int类型的数据,使用时却按long类型来加载如本地变量表中<br>4.符号引用验证。最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。符号引用验证可以看作是堆类自身以外的(常量池中的各种符号引用)的信息匹配性校验,如符号引用中通过字符串描述的全限定名是否能找到对应的类、符号引用中的类和字段一级方法的访问性是否可以被当前类访问(比如调用静态方法,检查调用的方法是否可以被当前类调用)
准备<br>为静态变量分配内存、赋初值。实例变量是在创建对象的时候完成赋值的,没有赋初值这一说。如果是被final修饰,在编译的时候会给属性添加ConstantValue属性,准备阶段直接完成赋值,即没有赋初值这一步<br>
```java<br>public class MyClassLoadHello {<br><br> public static int v = 10;<br><br> public static final int b = 11;<br> <br> public static void main(String[] args) {<br> int a = 1;<br> int b = 2;<br> System.out.println(a + b);<br> }<br>}<br>```<br>可以看到变量b多出了一个ConstantValue的属性,这个属性指向了常量池中11这个数值。准备阶段就会直接赋值
反观变量v则是在类的初始化<clinit>方法块中
为什么要在准备阶段赋初值?为何不直接赋值?<br>(C++对象)InstanceMirrorKlass对象只是创建出来,并没有属性,把这个变量写入到Class对象中去,如果这个静态变量没有使用到,也没有赋初值,字节码指令中将不包含该变量.<br>如图所示,变量m并没有在字节码指令中,因为没有赋初值也没有进行使用
通过HSDB可以发现InstanceMirrorKlass对象是有变量m这个属性的,但是InstanceKlass对象却显示只有两个静态属性。是不是很奇怪?字节码指令中都没有这个变量,InstanceMirrorKlass对象中却有这个属性。其实也不难理解,InstsanceKlass对象是存储在方法区中的,可以表示类的静态属性信息。由于这个属性没有赋值也没有使用,字节码层面就直接优化掉了,我们知道反射的时候可以获取到这个类的所有信息所有属性以及所有方法不管其作用域的范围是什么,如果不给InstanceMirrorKlass对象赋值这个属性,那么在反射的时候就会拿不到,这其实违背了反射的规则。所以要有静态属性赋初值这个动作,来给InstasnceMirrorKlass对象赋上这个属性。
解析<br>将常量池中的符号引用转为直接引用。解析后的信息存储在ConstantPoolCache类实例中。其中会涉及到如下:<br>1.类或接口的解析<br>2.字段解析<br>3.方法解析<br>4.接口方法解析<br><br>符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。可以理解为静态常量池的索引<br>直接引用(Direct References): 直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的.某个变量的内存地址<br><br>解析的时机?<br>1.加载阶段解析常量池时(类加载以后马上解析 resolve的参数需要改为 => true)<br>2.用的时候<br>解析什么?只要是直接引用都需要解析<br>1.继承的类、实现的接口<br>2.属性<br>3.方法<br>如何避免重复解析:<br>借助缓存,ConstantPoolCache(运行时常量池的缓存) if (klass -> is_resolved()) {}如图所示<br>常量池缓存:<br>key: 常量池的索引 2<br>value: String -> ConstantPoolEntry<br>静态属性是存储在堆区中的,<br>静态属性的访问:<br>1.去缓存中去找,如果有直接返回<br>2.如果没有就触发解析<br>底层实现:<br>1.会找到直接引用<br>2.会存储到常量池缓存中<br><br>openjdk是第二种思路,在执行特定的字节码指令之前进行解析:anewarray、checkcase、getfield、instanceof、invokeddynamic、invokeinterface、invokesepcial、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield.<br>
拓展知识:编译时常量池和运行时常量池<br>在Java中,常量池是class文件的一部分,它用于存储关于类和接口的常量以及一些符号引用。常量池分为两种:编译时常量池和运行时常量池。<br>1.编译时常量池(Constant Pool)<br>编译时常量池是在编译器生成的,它包含了类文件中的字面量(Literal)和符号引用(Symbolic References).<br>字面量:如文本字符串、final常量值等<br>符号引用:包括类和接口的全限定名、字段名称和描述符、方法名称和描述符。这些符号引用在类加载阶段或第一次使用时会被解析为直接引用。<br>编译时常量池时.class文件的一部分,它随着类文件的生成而生成,每个.class文件都有一个自己的编译时常量池<br>2.运行时常量池(Runtime Constant Pool)<br>运行时常量池是类或接口在JVM运行时的一部分,当类被JVM加载时,JVM会根据.class文件中的编译时常量池来创建运行时常量池。运行时常量池是方法区中的一部分。<br>动态性:运行时常量池具有动态性,它可以在运行期间想其中添加新的常量。例如,String的intern()方法可以将字符串常量添加到运行时常量池中<br>解析:运行时常量池中的符号引用会在类加载过程中或第一次使用时被解析为直接引用。<br>简而言之,编译时常量池是静态的,是.class文件的一部分,而运行时常量池是动态的,是JVM运行时数据区的一部分。运行时常量池在JVM的规范中是方法区的一部分,但在不同的JVM实现中可能会有所不同,如在HotSpot虚拟机中,它被放在了堆(Heap)中。
初始化<br>执行静态代码块,完成静态变量的赋值。类初始化阶段时类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制,到了初始化阶段,才真正开始执行类中定义的Java程序代码。在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度表达:初始化阶段时执行类构造器<clinit>()方法的过程。<br>1.<clinit>()方法时由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,可以在前面的静态语句块中赋值,但是不能访问,如图所示<br>2.<clinit>()方法与类的构造函数(或者说实例构造器<init>()方法)不同,它不需要显示地调用父类构造器,虚拟机会保证在子类地<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object<br>3.由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作<br>4.<clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法<br>5.虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,知道活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个进程阻塞(需要注意的是,其他线程虽然会被阻塞,但如果执行<clinit>()方法的那条线程退出<clinit>()方法后,其他线程唤醒之后不会再次进入<clinit>()方法。同一个类加载器下,一个类型只会初始化一次)。<br><br>何时初始化?主动使用时<br>1.new、getstatic、putstatic、invokestatic<br>2.反射<br>3.初始化一个类的子类会去加载其父类<br>4.启动类(main函数所在类)<br>5.当使用jdk1.7动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic、REF_put_static、REF_invoke_Static的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先触发其初始化。
<clinit>()方法执行死锁示例1:<br>```java<br>public class DeadLoopClass {<br><br> static {<br> if (true) {<br> System.out.println(Thread.currentThread() + "init DeadLoopClass");<br> while (true) {<br><br> }<br> }<br> }<br><br> public static void main(String[] args) {<br> Runnable script = new Runnable() {<br> @Override<br> public void run() {<br> System.out.println(Thread.currentThread() + "start");<br> DeadLoopClass dlc = new DeadLoopClass();<br> System.out.println(Thread.currentThread() + "end");<br> }<br> };<br><br> Thread thread1 = new Thread(script);<br> Thread thread2 = new Thread(script);<br> thread1.start();<br> thread2.start();<br><br> }<br>}<br>```<br>```java<br>Thread[main,5,main]init DeadLoopClass<br>```<br>一条线程在死循环模拟长时间操作,另外一条线程在阻塞等待执行clinit方法执行完毕后触发唤醒,但是一直等不到,所以就发生了死锁
<clinit>()方法执行死锁示例2:<br>```java<br>public class InitDeadLock {<br> public static void main(String[] args) throws InterruptedException {<br> new Thread(() -> new A()).start();<br> new Thread(() -> new B()).start();<br> }<br>}<br><br>class A {<br> static {<br> System.out.println("class A init");<br> new B();<br> }<br>}<br><br>class B {<br> static {<br> System.out.println("class B init");<br> new A();<br> }<br>}<br>```<br>一个线程创建A对象,进而触发A的初始化,但是A的clinit方法中又创建B,又触发B的初始化,另一个线程的初始化则反过来,资源获取顺序不当造成了死锁
卸载<br>判定一个类是否是"无用的类"的条件相对一个实例对象或者"废弃常量"要苛刻很多。类需要同时满足下面3个条件才能算是"无用的类":<br>1.该类的所有实例都已经被回收,也就是Java堆中不存在该类的任何实例。<br>2.加载该类的ClassLoader已经被回收<br>3.该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法<br>虚拟机可以堆满足上述3个条件的无用类进行回收,这里说的仅仅是"可以",而并不是和对象一样,不使用了,就必然会回收。<br>这也造成了很多人认为方法区(或者HotSpot虚拟机中的永久代)是没有垃圾收集的,Java虚拟机规范中确实说过可以不要求虚拟机在方法区中实现垃圾收集,而且在方法区中进行垃圾收集"性价比"一般比较低:在队中,尤其是在新生代中,常规应用进行一次垃圾收集一般可以回收70~95%的空间,而永久代的垃圾收集效率远低于此
静态字段如何存储<br>instanceKlass<br>instanceMirrorKlass<br>
Test_1_A<br>静态变量str的值存放在StringTable中,镜像类中存放的是字符串的指针
Test_1_B<br>str是类Test_1_A的静态属性,可以看到不会存储子类Test_1_B的镜像类中。<br>
可以猜得到,通过子类Test_1_B访问父类Test_1_A的静态字段有两种实现方式:<br>1.先去Test_1_B的镜像类中去取,如果有直接返回;如果没有,会沿着继承链将请求上跑。很明显,这种算法的性能随继承链的depth而上升,算法复杂度为O(n).<br>2.借助另外的数据结构实现,使用K-V的格式存储,查询性能为O(1)<br>Hotspot就是使用的第二种方式,借助另外的数据结构ConstantPoolCache,常量池类ConstantPool中有个属性_cache指向了这个结构。每一条数据对应一个类ConstantPoolCacheEntry.<br>ConstantPoolCacheEntry在哪儿?在CosntantPoolCache对象后面,代码位置/openjdk/hotspot/src/share/vm/oops/cpCache.hpp,代码如图所示。这个公式的意思是ConstantPoolCache对象的地址加上ConstantPoolCache对象的内存大小
ConstantPoolCache<br>常量池缓存是为常量池预留的运行时数据结构。保证所有字段访问和调用字节码的解释器运行时信息。缓存是在类被积极使用之前创建和初始化的。每个缓存项在解析时被填充。从图中的代码可以看出,是直接去获取ConstantPoolCacheEntry
类解析的过程ClassFileParser.cpp中的parseClassFile方法。
1.魔数验证
2.版本号验证
3.解析常量池、访问权限等等
4.解析类的接口信息、方法信息、属性信息、父类信息
5.创建位于方法区的InstanceKlass对象
6.创建位于堆中的InstanceMirrorClass对象
2.类加载器、双亲委派、SPI
类加载器<br>JVM中有两种类型的类加载器,由C++编写的及由Java编写的。除了启动类加载器(BootstrapClassLoader)是由C++编写的,其他都是由Java编写的,由Java编写的类加载器都继承自类java.lang.ClassLoader.JVM还支持自定义类加载器。各类加载器之间存在着逻辑上的父子关系,因为他们没有直接的从属关系
启动类加载器<br>因为启动类加载器是由C++编写的,通过Java程序去查看显示的是NULL,因此,启动类加载器无法被Java程序调用,启动类加载器不像其他类加载器有实体,它是没有实体的,JVM将C++处理类加载的一套逻辑定义为启动类加载器,加载的jar包如图所示。也可以通过-Xbootclasspath指定
首先我们找到启动main方法的入口
第二步,找到LoadMianClass
第三步,找到GetLauncherHelperClass中,findBootStrapClass方法。<br>
第四步
这套逻辑做的事情就是通过启动类加载器加载类sun.launcher.LauncherHelper,执行该类的方法checkAndLoadMian,加载main函数所在的类,启动扩展类加载器、应用类加载器也是在这个时候完成的
BootstrapClassLoader在JVM中对应的是ClassLoader.cpp。我们可以看到它包含的都是一些静态属性和静态方法<br>
扩展类加载器
ExtClassLoader的继承关系图
通过代码方式可以查看扩展类加载器加载的路径,也可以通过java.ext.dirs指定
应用类加载器
AppClassLoader的继承关系图
查看应用类加载器,它是默认加载用户程序的类加载器,也可以通过java.class.path指定
自定义类加载器
实现方式,需要继承java.lang.ClassLoader类,通过源码查看我们也得知了ClassLoader在loadClass的时候不会立马触发解析阶段,因为源码里面就写死了是false.懒汉模式,你可能听过loadClass()和findClass()方法,两者的职责是不一样的
loadClass方法与findClass方法分析:<br>1.loadClass方法是ClassLoader类中最常用的方法之一,它负责加载指定的类。它的主要特点是:<br>1.1 它是一个public方法,可以被外部类调用。<br>1.2 首先会检查类是否已经被加载,如果已经被加载,则直接返回对应的Class对象。<br>1.3 如果类没有被加载,它会调用findLoadedClass方法来检查类是否已经被其他类加载器加载<br>1.4 如果类仍然没有被加载,它会调用findClass方法(或者委托给父类加载器)来加载类。<br>1.5 如果上面的操作还是没有成功加载类,就抛出ClassNotFoundException一场<br><br>2.findClass方法是ClassLoader类中的一个protected方法,通常用于自定义类加载器时重写该方法<br>2.1 findClass方法负责从文件系统、网络或其他来源找到并读取类的字节码<br>2.2 在自定义类加载器时,通常重写findClass方法来实现特定的类加载逻辑。<br>2.3 当loadClass方法确定类尚未被加载,并且父类加载器没有加载该类时,它将调用findClass方法<br><br>在实现自定义类加载器时,通常会这样重写findClass:<br>1.根据类的全限定名(name参数)转换为文件路径<br>2.读取类的字节码文件<br>3.调用defineClass方法,将字节码转换成Class对象。<br><br>loadClass是用于外部调用的公共方法,负责整个类的加载过程,包括委托模型和类加载逻辑<br>findClass是用于被loadClass调用的受保护方法,通常在自定义类加载器时被重写以实现具体的类查找和字节码读取逻辑
loadClass方法与findClass方法
如何查看加载过的类?<br>findLoadedClass方法如果深入进去看的话,会发现其调用到了一个native的findLoadedClass0方法,这里的话,我们可以在openjdk的源码当中搜索
当我们再进一步查看的话,会发现看不到它的实现,这可能和C++的写法有关系,
它这里的方法实现其实是要到jvm.cpp文件中才能看到,具体的写法我也不是很清楚,但是可以看到,最后生成关键的一步是find_instance_or_array_klass这个方法
find_instance_or_array_klass方法中又调用了find(),我们再跟进去
我们可以发现,当查找加载过的类时,它是把类信息放到了一个hashtable里面,先通过class_name和类加载器loader计算出一个d_index,然后再通过这个索引去查找
如果在Dictionary字典里面找到了这个classname对应的类,则返回,没有则返回null,可以看到,查找加载过的类时,并不是直接拿着classname去找的,而是classname + classloader组合起来查找的.也就是key=>类的全限定名+类加载器 ->index value: Metadata:klass
类加载器创建链<br>启动类加载没有实体,只是将一段加载逻辑命名成启动类加载器。启动类加载器做的事情是:加载类sun.lanuncher.LanuncherHelper,执行该类的方法checkAndLoadMain...启动类、扩展类、应用类加载器逻辑上的父子关系就是在这个方法的调用链中生成的
我们知道JVM启动时会执行JavaMain,之后会执行JVM的相关初始化工作,这里先不谈,先看类加载器创建链。然后执行loadMainClass
获取LauncherHelper,它这个动作其实是让Bootstrap类加载器进行加载的
调用了FindBootstrapClass方法,
这里其实是要返回一个InstanceMirrorClass对象出来,如果加载过,则返回缓存即可,没有加载过,则进行加载
checkAndLoadMain方法中可以看到通过classloader加载该类,这也是类加载器加载一个类的流程,这个地方是要判断这个类应该交给哪个类加载器去加载,加载的细节其实就走到我们Java文件里面了ClassLoader的细节,那么这个scloader是怎么来的呢?
scloader是通过ClassLoader.getSytemClassLoader()方法创建的
在initSystemClassLoader方法里面核心逻辑是sum.misc.Lanuncher.getLauncher()
sun.misc.Launcher是C++里面的Java类,并不是我们rt.jar里面的,所以我们还得在HotSpot中去看,我们发现Launcher的构造方法中创建了扩展类加载器ExtClassLoader以及AppClassLoader
ExtClassLoader在创建的时候调用了super(getExtURLs(dirs), null, factory);由于它继承了URLClassLoader所以我们需要到它的父类里面去查看构造参数的细节
可以看到parent为null
在创建AppClassLoader的时候,把ExtClasLoader当作parent传给了构造参数,也就是说AppClassLoader和ExtClassLoader才具有真正意义上父子结构
再结合到java层面来看,当委托到ExtClassLoader的时候,由于它的parent为null,这个时候会调用findBootstrapClassOrNull
而findBootstrapClassOrNull方法底层又是一个native方法,这个时候就需要到C++里面去看了
这个方法会调用到ClassLoader.c(也就是对应的Bootstrap)。
看到SystemDictionary::resolve_or_null,返回由Bootstrap类加载器加载的InstanceMirrorClass
类加载器加载的类如何存储<br>1.Class<T>是访问类型T定义的Java程序入口,在Java代码中,如果你想获取T的定义,如查看其方法定义,字段定义,首先要获取到对应的Class<T>实例<br>2.ClassLoader实例本身对Java而言,也是一个分配在堆中的一个对象,它管理着自己在方法区的一个区域<br><br>元空间内部给各个类加载器划分了内存区域。另外还需要注意一点的是,方法区会有碎片化问题,方法区的垃圾回收通常被称为类的卸载。方法区回收之后是没有整理的<div></div>
双亲委派机制<br>如果一个类加载器收到了加载某个类的请求,则该类加载器并不会去加载该类,而是把这个请求委派给父类加载器,每一个层次的类加载器都是如此,因此所有的类加载器请求最终都会传送到顶端的启动类加载器;只有当父类加载器在其搜索范围内无法找到所需的类,并将该结果反馈给子类加载器,子类加载器会尝试自己加载
如何打破双亲委派?为什么要打破双亲委派机制?当前文件中搜索"<font color="#ec7270">Tomcat为什么要打破双亲委派机制"</font><br><font color="#000000">因为在某些情况下父类加载器需要委托子类加载器去加载class文件。受到加载范围的限制,父类加载器无法加载到需要的文件,以Driver接口为例,由于Driver接口定义在jdk当中的,而其实现由各个数据库的服务商来提供,比如mysql的就写了MySQL Connector,那么问题就来了,DriverManager(也由jdk提供)要加载各个实现了DriverManager接口的实现类,然后进行管理,但是DriverManager由启动类加载器加载,只能加载JAVA_HOME的lib目录下的文件,而其实现是由服务商提供的,由系统类加载器加载,这个时候就需要启动类加载器来委托子类来加载Driver实现,从而破坏了双亲委派机制。类似这样的情况就需要打破双亲委派。打破双亲委派的意思其实就是不委派、向下委派。</font>
线程上下文类加载器<br>1.是什么?一种特殊的类加载器,可以通过Thread获取,基于此可实现逆向委托加载<br>2.为什么?为了解决双亲委派的缺陷而生<br>3.怎么做?如图所示<br><br>SPI机制<br>它是一种服务发现机制。它通过在ClassPath路径下的META-INF/services文件夹查找文件,自动加载文件里所定义的类。这一机制为很多框架扩展提供了可能,比如在Dubbo、JDBC中都使用到了SPI机制。Tomcat/Spring就是这样类似的机制
沙箱安全<br>比如定义了一个类名为String所在包为java.lang,因为这个类本身是属于jdk的,如果没有沙箱安全机制的话,这个类将会污染到我所有的String,但是由于沙箱安全机制,所以就委托顶层的bootstrap加载器查找这个类,如果没有的话就委托extension,extension没有就到appclassloader,但是由于String就是jdk的源代码,所在bootstrap哪里就加载到了,先找到先使用,所以就使用bootstrap里面的String后面的一概不能使用,这就保证了不被恶意代码污染
openjdk源码会有很多这样的判断AccessController.doPrivileged
反射的底层原理<br>forName<br>getField<br>getMethod<br>通过这些方法获取一个类的信息,那么它是怎么存储的呢?它会去方法区中进行查找,那么在HotSpot中它是通过Dictionary字典来存储这些信息的,底层数据结构是hashtable<br>key:类的全限定名+类加载器->index<br>value: Metadata:klass<br>反射时需要先找到InstsanceKlass对象然后才能找到InstanceMirrorKlass对象,因为JVM是没法找到堆中的Class对象的,JVM找到InstanceKlass,就可以直接拿到InstanceMirrorKlass
3.JVM解析字节码文件过程
不同语言能在JVM上运行的本质
IDEA中如何查看字节码解析,安装binnary hex插件
大端与小端模式:<br>大端模式:高位存在低地址,低位存高地址<br>小段模式:与大端模式相反<br>
字节码文件组成
不同的JDK版本号所对应的major和minor版本号
常量池项<br>
String变量的最大长度为多少?<br>String变量在常量池中表示的结构是Constant_String_info结构,其中它的index指向了字符串字面量的索引,而字符串字面量的表示结构为Constant_Utf8_info,其中length字段表示UTF-8编码的字符串的长度,由两个字节组成表示的最大长度为2^16 -1 = 65535,但是Java虚拟机还需要1个字节的指令作为结束,所以其实真正的有效范围是65534,记住这是编译器的限制,运行期还是受制于int类型<br><br>为什么Java虚拟机还需要1个字节的指令作为结束?程序异常处理的有限范围解释<br>start_pc和end_pc两项的值表明了异常处理器在code[]数组中的有效范围。start_pc必须是对当前code[]数组中的某一指令的操作码的有效索引,end_pc要么是对当前code[]数组中某一指令的操作码的有效索引,要么等于code_length的值,即当前code数组的长度。start_pc的值必须比end_pc小。当程序计数器范围[start_pc,end_pc)内时,,异常处理器就将生效。即设x为异常句柄的有效范围内的值,x满足:start_pc<=x<end_pc.<br>实际上,end_pc值本身不属于异常处理器的有效范围,这点属于Java虚拟机历史上的一个设计缺陷:如果Java虚拟机中的一个方法的code属性的长度刚好是65535个字节,并且以一个字节长度的指令结束,那么这条指令将不能被异常处理器所处理。不过编译器可以通过限制任何方法、实例初始化方法或类初始化方法的code[]数组最大长度为65534,这样可以间接弥补这个BUG<br><br>Constsant_Uff8_info中为什么要存储字符串长度?<br>C++中因为存储了字符串的'\0',但是Java没有把这个\0编译进字节码文件中,C++要根据这个\0字符可以判断一个字符串的结束,java则是通过String的长度来判断一个字符串的结束
常量池中的0号索引是this指针,具体是什么想法不得而知。<br>
字节码中最难解析的是方法结构<br>
常量池其实只有三种数据结构类型,String比较特殊,还有就是4字节类型和8字节类型。如FieldI_info类型,它的结构如下:<br>class index:22 <br>nameAndType index:33<br>用一个short来存储 2个字节 :0xffff<br>22 << 16 0x2200<br>0x2200 | 0x33<br>4个字节合起来就是0x00220033拼起来存储的
字段描述符解释表
什么是描述符,具体解释见"字节码中的数据结构"
类访问和属性修饰符标志
表示方法访问权限及属性的各标志
字节码中的数据结构
field_info<br>在Java字节码中,field_info结构是用来描述类或接口中的字段(成员变量的)。每个field_info结构对应类文件中的一个字段。其中它的组成部分包括如下:<br>1.access_flags:访问标志,表示字段的访问级别(如public, private, protected, static等)和其他属性(如final volatile等)<br>2.name_index:字段名的索引,它是一个指向常量池的索引,常量池中的对应条目包含字段的名称<br>3.descriptor_index:字段描述符的索引,它也是指向常量池的索引,描述符用来表示字段的类型(如int,float, java.lang.String等)<br>4.attribute_count:属性数量,表示接下来跟随的属性表中的属性数量。<br>5.attributes:属性表,包含了对字段的额外描述信息,如常量值(ConstantValue)、字段签名(Signature)、Synthetic标记等。<br><br>例如,有这样一个类文件,代码如下:<br>```java<br>public class MyClass {<br> private int myField;<br>}<br>```<br>对应的field_info结构将包含如下信息:<br>1.access_flags:表示private访问权限<br>2.name_index:指向常量池中包含myField字符串的条目<br>3.descritptor_index:指向常量池中包含"I"(表示Int类型)的条目<br>4.attributes_count:通常为0,除非字段有额外的属性,如ConstantValue<br>
method_info、Code_attribute<br>在Java字节码中,method_info结构用于描述类或接口中的方法。每个method_info结构对应类文件中的一个方法。以下是method_info结构的组成部分:<br>1.acess_flags:访问标志,表示方法的访问级别(如public,private,protected, static等)和其他属性(如abstract. final,synchronized,native,strictfp等)<br>2.name_index:方法名的索引,这是一个指向常量池的索引,常量池中的对应条目包含方法的蒙城<br>3.descriptor_index:方法描述符的索引,这也是一个指向常量池中的索引,描述符用来表示方法的参数类型、返回值类型以及可能抛出的异常类型<br>4.attributes_count:属性数量,表示接下来跟随的属性表中的属性数量<br>5.attributes:属性表,包含了对方法的额外描述信息,如方法的代码(Code)、异常表(Exceptions)、方法签名(Signature)、注解(Annotation)等<br>例如,有这样一个类文件,代码如下:<br>```java<br>public class MyClass {<br> public void myMethod(int param) {<br> // Method body ...<br> }<br>}<br>```<br>对应的method_info结构将包含以下信息:<br>1.access_flags:表示public访问权限<br>2.name_index:指向常量池中包含myMethod字符串的条目<br>3.descriptor_index:指向常量池中包含(I)V(表示一个int参数且没有返回值)的条目<br>4.attributes_count:通常至少为1,因为大多数方法都会有一个Code属性,它包含方法的字节码指令<br><br>Code属性是method_info结构中最常见的属性,它包含以下字段:<br>1.max_stack:操作数栈的最大深度<br>2.max_locals:局部变量表所需的存储空间,单位是槽位(slot),每个槽位可以存储一个32位的数据类型<br>3.code_length:字节码指令的数量<br>4.code:实际的方法字节码指令序列<br>5.exception_table_length:异常表中的条目数量<br>6.exception_table:异常处理的信息<br>7.exception_table:异常处理的信息<br>8.attributes_count:Code属性中的属性数量<br>9.attributes:Code属性中的属性表
LineNumberTable和Code结构之间的关系是什么?<br>LineNumberTable是Code属性中的一个可选属性,它提供了源代码行号与字节码指令之间的映射关系。这样,当异常被抛出或者在调试程序时,可以更准确地定位到源代码中的具体行。<br>1.Code属性时method_info结构中的一个属性,它包含了方法的实际字节码指令、局部变量表大小、操作数栈大小等信息<br>2.LineNumberTable属性:是Code属性中的一个可选属性,它位于Code属性的attributes数组中,如果存在,它允许调试器和其他工具将执行的字节码指令与源代码中的行号关联起来<br><br>LocalVariableTable是Code属性中的一个可选属性,它提供了方法执行期间局部变量与局部变量表槽位之间的关系。这个映射允许调试器和其他工具在运行时查看和修改变量的值,并且可以用于在异常堆栈跟踪中显示变量名<br>1.Code属性:code属性包含方法的字节码指令、局部变量表大小、操作数栈等信息。它还包含一个属性表attributes,可以包含多个属性,比如LineNumberTalbe、LocalVariableTable等<br>2.LocalVariableTable属性:LocalVariableTable是Code属性中的一个属性,位于attributes数组中。如果存在,它描述了方法中的每个局部变量及其在局部变量表中的位置、生命周期和类型<br>3.LocalVariableTable中的每个条目都描述了一个局部变量,包括它的名字、类型、在局部变量表中的槽位索引以及它在字节码中的上明周期(开始和结束的偏移量)<br>4.LocalVariableTable提供了调试器在执行期间识别和访问局部变量的能力,这对于断点调试和异常堆栈跟踪非常有用<br>5.在编译时,编译器可以选择生成或不生成LocalVariableTable。如果生成了这个表,它会使得调试更加方便,但如果为了减小类文件大小或出于其他原因,这个表可能会被忽略
4.JVM内存模型与操作系统内存模型
Java进程在操作系统内存中的结构
可以这样理解:JVM内存模型其实就是JVM在启动的时候从操作系统内存中要了一块大内存,然后将这个大内存分成五个区域:方法区、堆区、虚拟机栈、本地方法栈、本地方法栈、程序计数器.其实叫JVM运行时区域更合适。但是要区分JVM内存模型与JMM(Java Memory Model)<br><br>InstanceKlass:类的元信息(方法区)<br>InstanceMirrorKlass:镜像类Class对象(堆区)<br><br>四个名词:<br>class文件:即硬盘上的.class文件<br>class content:类加载器将硬盘上的.class文件读入内存中的那一块内存区域<br>Class对象:<br>```java<br>Class<?> clazz = Test.class<br>```<br>对象:<br>Test obj = new Test();
方法区<br>方法区是虚拟机的一种规范<br>不同版本虚拟机堆方法区的具体实现<br>永久代(1.8之前是在堆区)<br>元空间(1.8之后,在直接内存上)<br><br>1.永久代的缺点?<br>放在堆上,很难触犯类的卸载机制<br>1.1 Class对象没有被使用<br>1.2 被三大类加载器加载的类不会被卸载,自定义类加载器才会被卸载<br>1.3 释放的内存很少<br>1.4 为什么早期没有一开始使用元空间的方式呢?早期是没有成熟的动态字节码技术的,现在cglib、asm技术、热更新技术可能会去创建新的类,会造成永久代的OOM,进而会引发堆区的OOM<br>2.元空间是如何解决?<br>2.1 不放在堆区,放在直接内存<br>3.元空间内部是如何存储的?元空间存在的问题?以及后面会如何优化<br>类加载器加载的类在元空间的存储形式。存在的内存碎片化问题。比如说在内存中存在一块4字节单位的区域和一块3字节单位的区域,此时要分配6字节,但虽然内存空间有7字节,但是因为不是连续的,所以导致没法分配。JVM内部不会存在太多。但是自定义类加载器中这个问题会比较明显,如Tomcat可以自己去实现整理算法以调用JNI的形式<br>如果一直向下兼容,问题将会一直存在,无法得到解决,以后可能会出现最低版本的支持<br><br><br>为什么要用元空间区替代永久代呢?()<br>1.内存碎片和垃圾回收问题<br>永久代是一个固定大小的内存区域,它存储了类的元数据、常量池等。随着时间的推移,永久代可能会发生内存碎片话,导致垃圾回收(GC)效率低下,甚至可能引发OutOfMemoryError错误。元空间使用的是本地内存,并且可以根据需要动态扩展和收缩,从而减少了内存碎片和GC问题<br>2.更灵活的内存管理<br>由于元空间使用的是本地内存,因此它不受JVM堆大小的限制。这意味着可以更灵活地管理内存使用,可以根据应用程序的需要分配更多的内存给元空间,而不会影响到java堆的大小<br>3.移除预定义的限制<br>由于元空间没有这样的预定义限制,它可以根据实际需求动态调整大小,这使得JVM更加健壮和可扩展。如果不指定的话,知道系统内存被使用完<br>4.简化的JVM架构<br>移除永久代简化了JVM的内存模型。现在,JVM的内存主要由堆(heap)、栈(stack)和本地内存中的元空间组成。这种简化有助于提高JVM的维护性和可理解性<br>5.更好的兼容性<br>随着Java应用程序和类库的不断发展,对元数据的需求也在不断增长,使用元空间可以更好地适应这种增长,因为它不受固定大小的限制<br>6.减少Full GC的影响<br>永久代的垃圾回收是Full GC的一部分,这通常会导致较长的停顿时间。由于元空间使用了不同的垃圾回收策略,可以减少Full GC的频率和影响
C++中Hotspot是如何将Klass对象放到方法区的?<br>C++有个技术叫做操作符重写,/vm/memory/allocation.hpp,操作符重写:new可以指定这个对象存在哪里
Java虚拟机栈<br>Java虚拟机栈(Java Virtual machine Stacks)是线程私有的,它的声明周期与线程相同。<font color="#e74f4c">虚拟机栈描述的是Java方法执行的内存模型</font>:每个方法在执行的同时都会创建一个栈帧(Stack Frame(栈帧是方法运行时的基础数据结构))用于存储以下几个部分<br>1.局部变量表<br>2.操作数栈<br>3.动态连接<br>4.方法出口/返回地址<br>5.附加信息<br><br>经常有人把Java内存区分为堆内存(heap)和栈内存(stack),这种分发比较粗糙,java内存区域的划分实际上远比这复杂。这种划分方式的流行只能说明大多数程序员最关注的、与对象分配关系最密集的内存区域是这两块。<br><br>虚拟机栈和线程个数比为1:1<br>一个虚拟机栈中有多少栈帧?跟方法的调用次数成正比<br>
局部变量表<br>局部变量表存放了编译器可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象的起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间时完全确定的,在方法运行期间不会改变局部变量表的大小。<br><br>在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常<br><br>局部变量表(Local Variable Table)是一组变量值存储空间,用于存放<font color="#e74f4c">方法参数和方法内部定义的局部变量</font>。在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。<br>局部变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位,虚拟机规范中并没有明确指明一个Slot应占用的内存空间大小,只是很有导向性地说到每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress累心地数据,这8种苏韩剧类型,都可以用32位或更小地物理内存来存放,但这种描述与明确指出"每个Slot占用32位长度地内存空间"是有一些差别地,它允许Slot的长度可以随着处理器、操作系统或虚拟机的不同而发生变化。只要保证即使在64位虚拟机中使用了64位的物理内存空间区实现一个Slot,虚拟机仍要使用对齐和补白的方式让Slot在外观上看起来与32位虚拟机中的一致。<br><br>既然前面提到了Java虚拟机的数据类型,在此再简单介绍一下他们。一个Slot可以存放一个32位以内的数据类型,Java中占用32位以内的数据类型有boolean、byte、char、short、int、float、reference(Java虚拟机规范中没有明确规定reference类型的长度,它的长度与实际使用32还是64位u虚拟机有关,如果是64位虚拟机,还与是否开启某些对象指针压缩的优化有关,这里暂且只取32位虚拟机的reference长度)和returnAddress8种类型。前面6中不需要多家解释,可以按照Java语言中对应数据类型的概念区理解它们(仅是这样理解而已,Java语言与Java虚拟机中的基本数据类型是存在本质差别的),而第7种reference类型表示对一个对象实例的引用,虚拟机规范既没有说明它的长度,也没有明确指出这种引用应有怎样的结构。但一般来说,虚拟机实现至少都应当能通过这个引用做到两点:<br>1.从此引用直接或间接地查找到对象在堆中的数据存放的起始地址索引<br>2.此引用中直接或间接地查找到对象所属数据类型在方法去中的存储的类型信息,否则无法实现Java语言规范中定义的语法约束<br>(并不是所有语言的对象引用都能满足这两点,例如C++语言,默认情况下(不开启RTTI支持的其概况),就之只能满足第一点,而不满足第二点。这也是为何C++中提供Java语言里很常见的反射的根本原因)<br>对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间,Java语言中明确的(reference类型则可能是32位也可能是64位)64位的数据类型只有long和double两种。值得一提的是,这里把long和double数据类型分割存储的做法与"long和double的非原子性协定"中把一次long和double数据类型读写分割位两次32位读写的做法有些类似。不过局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的Slot是否为原子操作,都不会引起数据安全问题。<br>虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的Slot数量。如果访问的是32位数据类型的变量,索引n就代表了使用第n个Slot,如果是64位数据类型的变量,则说明会同时使用n和n+1两个Slot。对于两个相邻的共同存放一个64位数据的两个Slot,不允许采用任何方式单独访问其中的某一个,Java虚拟机规范中明确要求了如果遇到进行这种操作的字节码序列,虚拟机应该在类加载器的校验阶段抛出异常。<br><br><font color="#e74f4c">编译优化:方法内部代码块中的变量是不会写到字节码文件中的</font>
为什么C++提供Java语言里反射的原因?<br>1.现代语言特性需求:随着变成语言的发展,现代变成语言普遍支持反射机制,因为它可以大大提高程序的灵活性和可扩展性。C++作为一门长期发炸你的语言,也在不断地更细你和增加新特性,以保持其竞争力<br>2.运行时类型信息(RTTI):C++中的反射机制是通过运行时类型信息实现的,这允许程序在运行时获取对象的类型信息,并进行相应的操作。这是实现多态、动态绑定等高级编程概念的基础<br>3.框架和库开发:反射机制杜宇框架和库的开发尤为重要,因为它可以使这些框架和库更加通用和强大。例如,它可以使序列化、反序列化、对象关系映射(ORM)等操作更加容易实现<br>4.增强互操作性:C++与其他支持反射的语言(如Java、C#等)进行交互时,反射机制可以提供更好的互操作性。例如C++/CLI是一种特殊的C++方言,用于与.NET框架交互,其中就包含了反射特性<br>5.动态编程:虽然C++是一门静态类型语言,但在某些情况下,开发者可能需要动态编程的能力,例如在脚本语言或插件系统中。反射可以提供这种能力<br>6.社区需求:长期以来,C++社区中一直有呼声要求增加反射机制。随着标准的更新,C++委员会逐渐考虑将这些需求纳入语言标准<br>7.代码生成和元编程:反射机制可以与模板元编程结合使用,以实现更高级的代码生成技术,这在一些复杂的系统中非常有用<br><br>需要注意的是,C++的反射机制与传统上Java中的反射并不完全相同。C++的反射能力相对较弱,通常是通过RTTI和模板元编程等技术部分实现的。而且直至2024,C++标准中并没有完整的反射机制,但有一些提案正在尝试将更完整的反射特性引入C++
操作数栈<br>操作数栈(Operand Stack)也常称为操作栈,他是一个后入先出(Last In First Out, LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和double.32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为。在方法执行的任何时候,操作数栈的深度都不会超过max_stacks数据项中设定的最大值。<br>当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递(A方法的返回值作为B方法的入参这种)。<br>举个例子,整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int类型的数值,当执行这个指令的时候,会将这两个int值出栈并相加,然后将相加的结果入栈。<br>操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。再以上面的iadd指令为例,这个指令用于整型数假发,它在执行时,最接近栈顶的两个元素的数据类型必须为int类型,不能出现一个long和一个float使用iadd命令相加的情况。<br>另外,在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但在大多数虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以公用一部分数据,无需进行额外的参数复制传递,重叠的过程如图所示。Java虚拟机的解释执行引擎称为"基于栈的执行引擎",其中所指的"栈"就是操作数栈。
动态链接<br>每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。我们知道Class文件的常量池中有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接
方法返回地址<br>当一个方法开始执行后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这死后可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。<br>无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。<br>方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可以能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
附加信息<br>虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现,在实际开发中,一般会把动态链接、方法返回地址与其他附加信息全部归为一类称为栈帧信息
本地方法栈<br>与虚拟机栈发挥的作用是相似的,他们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。在虚拟机规范中对本地方法栈中使用、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机 比如(SUn HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryErr异常<br>调用JNI程序(安卓需要操作硬件),Java没有办法直接操作硬件。只能用C/C++汇编调用需要加载动态链接库(Sytem.loading())手动加载jar包<br>```java<br>// 点亮屏幕<br>public static native void light();<br>```<br>这些动态链接库下面是一些操作系统内核中的驱动。在做嵌入式的驱动硬件中,之前是嵌入Linux内核,现在是嵌入Android系统。<br>Java通过JNI调用C/C++动态链接库需要的栈,随着socket的发展,JNI技术已经用得非常非常少了
堆<br>对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。这一点在Java虚拟机规范中的描述是:所有对象实例以及数组都要在堆上分配,但是随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得表示那么"绝对"了。Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做"GC堆"(Garbage Collected Heap,幸好国内没有翻译成"垃圾堆"),从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分:新生代和老年代:再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB).不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。<br>根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。<br>堆区min:1/64 物理内存 max:1/4物理内存<br><br>为什么老年代空间>新生代空间? 因为老年代要存储的东西比新生代多<br>1.GC大于15的对象(跟对象头有关,4个bit)<br>2.空间担保机制<br>3.动态年龄判断机制:新生代没有足够的空间存放,<br>为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象地年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小地综合大于Survivor空间地一般,年龄大于或等于该年龄地对象就可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄。<br>4.大对象<br>所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。大对象对虚拟机的内存分配来说就是一个坏消息(比遇到一个大对象更加坏的消息就是遇到一群"朝生夕死"的"短命大对象",写程序的时候应当避免),经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来"安置"它们。虚拟机提供了一个参数,大于这个参数的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制<br><font color="#e74f4c">内存模型中最核心的区域,也是JVM调优重点关注的区域</font>
对象的创建<br>DCL中单例对象的创建为什么要加volatile?<br>如果在ns级别的超高并发是需要加volatile关键字的,这个关键字会禁止指令重排,因为CPU是会乱序执行这些指令的。如下指令<br>接下来我们看下一个对象的创建流程<br>0 new #2<br>1.堆区申请了内存(不完全对象)构造方法还未执行<br>2.内存地址压入栈<br>3 dup duplicate<br>1.赋值栈顶元素<br><br>为什么要复制?<br>因为接下来调用init非静态方法,但是this指针还是空的,所以需要把栈顶元素弹出去,给this指针赋值,然后再把元素压入栈<br>2.再次压入栈<br>4 invokespecial #3 <init>方法<br>this指针<br>this = null<br><br>执行方法分为两步:<br>1.构建环境会涉及到创建栈帧、传参、保存现场。给this指针赋值,记录方法的执行之前的位置<br>2.执行<br><br>7 astore_1<br>1.pop出元素:对象的指针<br>2.赋值给index=1的位置的变量(局部变量)
对象的创建<br>Java是一门面向对象的编程语言,在Java程序运行过程中无时无刻都有对象被创建出来。在语言层面上,创建对象(例如克隆、反序列化)通常仅仅是一个new关键字而已,而在虚拟机中,对象(引用类型的对象)的创建又是一个怎样的过程呢?<br>虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号一弄,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那必须先执行响应的类加载过程。<br>在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针想空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为"指针碰撞"(Bump the Pointer)。如果Java堆中的内存并不是规整的,已使用内存和空闲的内存相互交错,那就没有办法简单进行指针碰撞了,虚拟机就必须维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为"空闲列表"(Free List).选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法时指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表<br>(内存分配算法也跟对象的存活周期有关,新生代大部分对象都朝生夕死,复制算法进行GC完之后,内存就是规整的,而老年代,存活对象相比新生代来说存活率要高,内存不太容易规整,如果不带整理的话,使用指针碰撞失败的概率会高很多。所以老年代采用空闲列表)<br>除如何划分可用空间之外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。解决这个问题有两种方案,一种是堆分配内存空间的动作进行同步处理——实际上迅即采用CAS配上失败重试的方式保证更新操作的原子性;另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。虚拟机是否使用了TLAB,可以通过-XX:+/-UseTLAB参数来设定<br><br>内存分配完成之后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。<br><br>接下来,虚拟机要对对象进行必要的设置,例如这个对象时哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前的运行状态不同,如是否启用偏向锁等,对象头会有不同的设置方式。<br><br>在上面的工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java程序的视角来看,对象创建才刚刚开始——<init>方法还没有执行,所有的字段都还为0,所以,一般来说(由字节码中是否跟随invokespecial指令所决定),执行new指令之后会接着执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全生成出来
HotSpot源码,_new字节码指令分析<br>1.确保常量池中存放的是已解释的类<br>2.确保对象所属类型已经经过初始化阶段<br>3.取对象长度<br>4.记录是否需要将对象所有字段置为零<br>5.是否在TLAB中分配对象,否则直接在Eden中分配对象.cmpxchg是x86中的CAS指令,这里是一个C++方法,通过CAS方式分配空间,如果并发失败,转到retry中充实,知道成功分配为止<br>6..如果需要,则为对象初始化零值<br>7.根据是否启用偏向锁来设置对象头信息<br>8.将对象引用入栈,继续执行下一条指令
AQS 管程思想
JVM内存模型的设计与GC收集器有很大关系。<br>HotSpot源码中是根据垃圾收集器的类型来创建不同的对象内存模型的<br>G1之前是新生代 + 老年代<br>之后划分2048个块,每个块大小为2M,基于Region模型<br>新生代中在一轮GC之后,90~95%的对象都会被回收,采用复制的话,避免了内存整理,操作比较简单。<br>分代+复制算法:为了保证内存使用的高效性<br>标记-清楚算法: 会有碎片化问题<br>标记-整理算法:整理算法 内存合并算法 耗费CPU,<br>
5.剖析字符串与数组的底层实现
字符数组的存储方式<br>JVM有三种模型:<br>1.Oop模型:Java对象对应的C++对象<br>2.Klass模型:Java类在JVM对应的C++对象<br>3.handle模型
字符串常量池<br>即String Pool,但是JVM中对应的类是StringTable,底层实现是一个hashtable,如代码所示<br><br>JVM有三种常量池:<br>1.静态常量池(通过字节码方式查到地引用都是间接引用)<br>2.运行时常量池<br>3.字符串常量池->StringTable<br>key生成规则->String内容+长度生成哈希值,然后将hash值取模转为key<br>value生成规则->将Java地String类的实例InstanceOopDesc封装成HashtableEntry
字符串常量池位置<br>JDK1.6及之前:有永久代,运行时常量池在永久代,运行时常量池包含字符串常量池,Perm区域只有4m,一旦常量池大量使用intern很容易发生永久代的OOM<br>JDK1.7:有永久代,但已经逐步"去永久代",字符串常量池从永久代里的运行时常量池分离到堆里<br>JDK1.8及之后,无永久代,运行时常量池在元空间,字符串常量池依然在堆里
字符串常量池的设计思想:<br>1.字符串的分配,和其他的对象分配一样,耗费高昂的时间与空间代价,作为最基础的数据类型,大量频繁的创建字符串,极大程度地影响程序地性能<br>2.JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化<br>2.1 为字符串开辟一个字符串常量池,类似于缓存区<br>2.2 创建字符串常量时,首先查询字符串常量池是否存在该字符串<br>2.3 存在该字符串,返回引用实例,不存在,实例化该字符串并放入池中
字符串常量池设计原理<br>字符串常量池底层时HotSpot的C++实现的。底层类似一个Hashtable,保存的本质上是字符串对象的引用,来看一道比较常见的案例,图中的代码创建了多少个String对象<br>// JDK6:false 创建了6个对象<br>// JDK7及以上:true 创建了5个对象<br><br>为什么输出会有这些变化呢?主要还是字符串从永久代中脱离、移入堆区的原因,intern()方法也相应发生了变化<br>同时也解释了JDK1.6中字符串溢出会抛出OutOfMemoryError:PermGen Space.而在JDK1.7及以上版本会抛出OutOfMemoryError:Java heap space
在JDK1.6中,调用intern()首先会在字符串池中寻找equals相等的字符串,加入字符串存在就返回该字符串在字符串池中的引用,假如字符串不存在,虚拟机会重新在永久代上创建一个实例,将StringTable的一个表项指向这个新创建的实例
在JDK1.7(及以上版本)中,由于字符串池不在永久代了,intern()做了一些修改,更方便地利用堆中的对象。字符串存在时和JDK1.6一样,但是字符串不存在时不再需要重新创建实例,可以直接指向堆上的实例
美团技术分享代码一:<br>JDK1.7以上:false true<br>JDK1.6: false false
jdk1.6代码图<br>在JDK1.6中上述的所有打印都是false,因为jdk6的常量池是放在Perm区中的,Perm区和正常的Java Heap区域是完全分开的。如果是使用引号声明的字符串都是会直接在字符串常量池中生成,而new出来的String对象是放在Java Heap区域。所以拿一个Java Heap区域的对象地址和字符串常量池的对象地址进行比较比较肯定是不相同的,即使调用String.inern方法也是没有关系的
jdk1.7代码图<br>这里要明确一点的是,在Jdk6以及以前的版本中,字符串的常量池是放在堆的Perm区的,Perm区是一个类静态的区域,主要存储一些加载类的信息,常量池,方法片段等内容,默认大小只有4m,一旦常量池中大量使用intern是会直接产生java.lang.OutOfMemoryError:PermGen Space错误的。Perm区域太小是一个主要原因,当然在1.8中已经直接取消了Perm区域,而新建立了一个元区域。应该是jdk开发者认为Perm区域已经不适合现在Java的发展了。<br>正是因为字符串常量池移动到Java Heap区域后,再看下面解释。<br>1.先看s3和s4字符串,String s3 = new String("1") + new String("1");这句代码中现在生成了两个对象,一个是字符串常量池中的"1",另一个是Java Heap中的s3引用指向的对象。中间还有2个匿名的new String("1"),不去讨论他们,此时s3引用对象内容是"11",但此时常量池中是没有"11"对象的<br>2.接下来,s3.intern(); 这一句代码,是将s3中的"11"字符串放入String常量池中,因为此时常量池中不存在"11"字符串,因此常规做法是跟jdk6图中所示的一样,再常量池中生成一个"11"的对象,关键点是jdk7中常量池不在Perm区域了,这块做了调整。常量池中不需要再存储一份对象了,可以直接存储堆中的引用。这份引用指向s3引用的对象。也就是说引用地址是相同的<br>3.最后String s4 = "11";这句代码中"11"是显式声明的,因此会直接区常量池中创建,创建的时候发现已经有这个对象了,此时也就是指向s3引用对象的一个引用。所以s4引用就指向和s3一样了,因此最后的比较s3 == s4 是true<br>4.再看s和s2对象,String s = new String("1");第一句代码,生成了2个对象。常量池中的"1"和Java Heap中的字符串对象 s.intern()这一句是s对象区常量池中寻找后发现"1"已经再常量池里了<br>5.接下来String s2 = "1";这句代码是生成一个s2的引用指向常量池中的"1"对象。结果就是s和s2引用地址明显不同
美团技术分享代码二:<br>JDK1.7以上:false false<br>JDK1.6 false false<br>
1.代码一和代码二的改变就是s3.intern的顺序是放在了String s4 = "11"后了,这样,首先执行String s4 = "11";声明s4的时候常量池中是不存在"11"对象的。执行完毕后"11"对象是s4声明产生的对象。然后再执行s3.intern时,发现常量池中"11"对象已经存在了,因此s3和s4的引用时不同的<br>2.s和s2代码中,s.intern()这一句往后放也不会有什么影响了,因为对象池中执行第一句代码String s = new String("1");的时候已经生成"1"对象的了,下边的s2声明都是直接从常量池中取地址引用的,s和s2的引用地址是不会相等的
key的生成方式<br>1.通过String的内容 + 长度生成hash值<br>2.将hash值转为key
hash生成方式
通过hash计算索引
value的生成方式<br>将Java的String类的实例InstanceOopDesc封装成HashtableEntry
字符串jdk8和jdk9的区别
jdk8:底层是一个char[]数组<br>jdk9及之后:底层是一个byte[]数组<br><br>一个中文占两个字节,一个char占两个字节,一个byte占一个字节<br>Jdk9及之后的版本中,多了一个code属性,这个属性标记是告诉调用者按几个字节来取的问题,<br>如果按byte存储的话,存不是什么问题,问题是如何取呢?<br>String在9版本之后是根据code属性来区分的:<br>如果是英文则按照LATIN编码格式,1个字节1个字节地取<br>如果是中文则按照UTF16两个字节来取<br><br>JDK9的字符串去重:<br>G1:经历了三次GC,-XX:+UseG1GC -XX:+UseStringDeduplication<br>底层原理:用== 判断不是同一个字符串,内容是否相等<br>优缺点:它是一个CPU密集型的
String类重写了hashCode方法.<br>可以看出String的hashcode与String的内容是有关系的
String类也重写了equals方法
不同方式创建字符串在JVM中的存在形式
双引号<br>这种方式创建的字符串对象,只会在常量池中。因为"11"这个字面量,创建对象s1的时候,JVM会先去常量池中通过equals(key)方法,判断是否有相同的对象。<br>如果有,则直接返回该对象在常量池中的引用;<br>如果没有,则会在常量池中创建一个新对象,再返回引用
new String<br>这种方式会保证字符串常量池和堆中都有这个对象,没有就创建,最后返回堆内存中的对象印象。<br>步骤大致如下:<br>因为有"11"这个字面量,所以会先检查字符串常量池中是否存在字符串"11"<br>不存在,先在字符串常量池里创建一个字符串对象;再去堆内存中创建一个字符串对象"11";<br>存在的话,就直接去堆内存中创建一个字符串对象"11";<br>最后,将堆内存中的引用返回
两个双引号
两个new String
拼接字符串底层是如何实现的<br>StringBuilder拼接而成的字面量是不会放入到常量池中的
双引号 + 双引号
双引号 + new String
intern方法<br>native方法,作用是"如果常量池中存在当前字符串,就会直接返回当前字符串。如果常量池中没有此字符串,会将此字符串放入常量池中后,再返回"。如图所示。它的大体实现结构是JAVA使用JNI调用C++实现的StringTable的intern方法,StringTable的intern方法跟Java中的HashMap的实现是差不多的,只是不能自动扩容。默认大小是1009.要注意的是String的StringPool是一个固定大小的Hashtable,默认值大小是1009,如果放进StringPool的String非常多,就会造成Hash冲突严重,从而导致链表会很长,而链表长了后直接会造成的影响就是当调用String.inern时性能会大幅下降(因为要一个一个找)。<br><font color="#e74f4c">在JDK6中StringTable是固定的,就是1009的长度,所以如果常量池中的字符串过多就会导致效率下降很快。在JDK7中,StringTable的长度可以通过一个参数执行:-XX:StringTableSize=99991</font>.<br><br>String s = new String("abc");<br>在上面的语句中创建了两个对象,第一个对象是"abc"字符串存储在常量池中,第二个对象是在Java Heap中的String对象<br><font color="#e74f4c">如果StringTable单链表查找超过100次触发rehash</font><br><br>String s1 = "cover";<br>String s2 = s1.intern();<br>System.out.println(s1 == s2); // false<br><font color="#e74f4c">在JDK1.6中需要将s1复制到字符串常量池里</font>
6.亿级流量并发系统到底是怎么调优的
oop模型<br>前面的klass模型,它是Java类的元信息在JVM中的存在形式。这个oop模型是Java对象在JVM中的存在形式
内存分配策略:<br>1.空闲列表<br>2.指针碰撞(jvm采用的)<br>2.1 top指针:执行的是可用内存的起始位置<br>2.2 采用CAS的方式<br>3.TLAB 线程私有堆<br>4.PLAB 老年代的线程私有堆
1.空闲列表<br>OS把不常用的内存写到硬盘上,如果有进程需要读取引发缺页异常,进而会去硬盘上读
空闲列表机制<br>在操作系统中,内存分配策略的空闲列表机制是一种管理内存资源的方法。以下是其基本原理和步骤<br>1.基本原理:<br>1.1 内存块管理:操作系统将内存划分为多个块(block),每个块可以是空闲的,也可以是已分配的<br>1.2 空闲列表:操作系统维护一个记录所有空闲内存块的列表,称为空闲列表。这个列表通常会记录每个空闲块的大小和起始地址。<br>2.步骤:<br>2.1 初始化:当系统启动时,除了操作系统本身占用的内存外,其余的内存都被视为一个大的空闲快,并被加入到空闲列表中<br>2.2 分配内存:<br>2.2.1 当一个进程请求内存时,操作系统会根据请求的大小在空闲列表中查找何时的空闲块<br>2.2.2 查找策略可以是首次适配(first fit)、最佳适配(best fit)或最坏适配(worst fit)<br>2.2.3 一旦找到合适的空闲块,操作系统会从空闲列表中移除该块,并将其标记为已分配,然后将内存分配给请求的进程<br>2.3 内存释放<br>2.3.1 当进程释放内存时,操作系统会回收这块内存,并将其标记为空闲<br>2.3.2 操作系统可能会将这块空闲与周围的空闲块合并,形成一个更大的空闲块,以减少内存碎片<br>2.3.3 合并后的空闲块或新的空闲块会被重新加入到空闲列表中<br>2.4 碎片整理<br>2.4.1 随着内存的分配和释放,内存可能会出现碎片化,即空闲内存分散在各个角落,导致无法满足大的内存请求<br>2.4.2 空闲列表机制可能会通过移动已分配的内存块来整理碎片,但这在实际操作中可能比较复杂且耗时<br><br>优点:<br>简单性:空闲列表机制相对简单,易于实现<br>灵活性:可以根据不同的内存分配策略(如首次适配、最佳适配等)来优化内存使用<br>缺点:<br>维护开销:随着内存分配和释放的频繁进行,空闲列表的维护可能会带来一定的开销<br>内存碎片:可能导致内存碎片,尤其时当空闲块和已分配块的大小频繁变动时。
操作系统为什么不采用指针碰撞机制进行内存分配?<br>操作系统不采用指针碰撞的机制进行内存分配,主要是因为操作系统的内存管理需要面对复杂和多样化的环境。以下是一些关键原因:<br>1.多任务和多用户<br>操作系统:必须支持多个进程和线程的运行,每个进程或线程可能需要不同大小的内存,且分配和释内存的时间点是随机的<br>指针碰撞:适用于单一连续内存分配的场景,不适合处理多任务环境下的复杂内存需求<br>2.内存碎片<br>操作系统:需要处理内存碎片问题,因为不同大小的内存块被分配和释放后,内存中可能会留下无法被利用的小空闲块<br>指针碰撞:不擅长处理内存碎片,因为它假设内存分配是连续的,如果内存碎片严重,指针碰撞机制将无法有效工作<br>3.内存分配的灵活性<br>操作系统:需要能够分配任意大小的内存块以满足不同进程的需求<br>指针碰撞:通常需要一个连续的内存区域,并且当内存区域不足以容纳新分配的内存块是,需要额外的机制来处理这种情况<br>4.物理内存与虚拟内存<br>操作系统:使用虚拟内存技术,将物理内存与虚拟内存地址映射起来,这使得内存分配更加复杂<br>指针碰撞:适用于简单的物理内存分配,不涉及复杂的地址映射<br>5.安全性和隔离性<br>操作系统:需要确保不同进程的内存是隔离的,防止一个进程访问或修改另一个进程的内存<br>指针碰撞:需要额外的机制来保证内存的安全性和隔离性<br>6.性能考量<br>操作系统:必须高效地管理内存以满足性能需求,这通常意味着需要一个能够快速响应的内存分配策略<br>指针碰撞:虽然分配速度快,但在多任务环境下,它可能导致内存利用率低下,因为它可能留下很多小的空闲内存块<br>7.系统调用和API<br>操作系统:提供了系统调用和API供应用程序请求和释放内存,这些调用需要能够处理各种复杂的内存分配请求<br>指针碰撞:无法直接适应这些系统调用和API的需求<br>
2.指针碰撞(CAS)<br>如图所示,bottom指向内存区域的头部,end指向内存区域的尾部,top指针开始指向头部(可用内存的起始位置)。如果new_top = top(当前top) + 对象大小满足这个等式,则分配成功,并top=new_top<br>
为什么JVM不采用空闲列表的内存分配策略而是采用指针碰撞的形式?<br>Java虚拟机(JVM)内存分配策略与操作系统分配策略的不同,主要由以下几个因素决定的:<br>1.内存管理的抽象层级不同:<br>操作系统:操作系统负责管理物理内存,直接与硬件交互,需要处理多种复杂情况,如内存碎片、多进程/线程的内存需求等<br>JVM:JVM运行在操作系统之上,主要负责管理Java程序的运行时内存,它对内存的管理更加抽象化,并且通常不需要处理硬件级别的内存碎片问题<br>2.内存分配的特点<br>空闲列表:适用于需要频繁分配和释放不同大小内存的场景,且物理内存可能存在碎片<br>指针碰撞:适用于对象大小相对一致且频繁创建和销毁的场景,如JVM中的对象分配<br>3.JVM内存分配的具体考虑<br>效率:指针碰撞时一种非常高效的内存分配方式。在JVM中,当一个新的对象需要被分配时,只需要移动以下指针(分配指针),而不需要遍历整个空闲列表来查找合适的内存块。这大大减少了内存分配的开销<br>内存连续性:指针碰撞可以保证分配的内存是连续的,这对于提高缓存命中率有好处,因为连续的内存访问往往能更好地利用CPU缓存<br>内存碎片:由于JVM通常会分配和释放大量大小相似的对象,内存碎片问题不像在操作系统中那么严重。JVM通过垃圾回收来管理内存,看可以在GC过程中重新整理内存,减少碎片<br>垃圾回收:JVM采用垃圾回收机制来自动管理内存。当对象不再被引用时,垃圾回收期会自动回收他们所占用的内存。这种方式与空闲列表的内存分配策略相比,减少了手动内存释放的复杂性,并且通过不同的垃圾回收u算法来优化没存使用<br>4.JVM的内存模型:<br>堆空间:JVM的堆空间是用于存储Java对象的地方,通常分为老年代和新生代等,不同代的内存管理策略不同。新生代采用复制算法,老年代采用标记-清除或标记-整理算法。这些算法与指针碰撞的内存分配策略更为契合。<br><br>JVM选择指针碰撞的内存分配策略,而不是空闲列表,是因为这种策略更符合JVM内存管理的需求,能够提供更高的内存分配和回收效率,并且与JVM的垃圾回收机制更为兼容
JVM中的内存分配策略为什么不使用空闲列表的方式而是采用指针碰撞
1.操作系统的内存分配策略采用的空闲列表机制是什么?()<br> 在操作系统中,内存分配策略的空闲列表机制是一种管理内存资源的方法。<br> 基本原理:<br> 1.内存块管理:操作系统将内存划分为多个块(block),每个块可以是空闲的,也可以是已分配的<br> 2.空闲列表:操作系统维护一个记录所有空闲内存块的列表,称为空闲列表。这个列表通常会记录每个空闲块的大小和起始地址<br> 步骤:<br> 1.初始化:当系统启动时,除了操作系统本身占用的内存外,其余的内存都被视为一个大的空闲块,并被加入到空闲列表中<br> 2.分配内存:<br> a.当一个进程请求内存时,操作系统会根据请求的大小在空闲列表中查找合适的空闲块<br> b.查找策略可以时首次适配(first fit)、最佳适配(best fit)或最坏适配(worst fit)等<br> c.一旦找到合适的空闲块,操作系统会从空闲列表中移除该块,并将其标记为已分配,然后将内存分配给请求的进程<br> 3.内存释放<br> a.当进程释放内存时,操作系统会回收这块内存,并将其标记为空闲。<br> b.操作系统可能会将这块空闲内存与周围的空闲块合并,形成一个更大的空闲块,以减少内存碎片<br> c.合并后的空闲块或新的空闲块会被重新加入到空闲列表中<br> 4.碎片整理<br> a.随着内存的分配和释放,内存可能会出现碎片化,即空闲内存分散在各个角落,导致无法满足大的内存请求<br> b.空闲列表机制可能会通过移动已分配的内存块来整理碎片,但这在实际操作中可能比较复杂耗时<br> <br> 优点:<br> 1.简单性:空闲列表机制相对简单,易于实现<br> 2.灵活性:可以根据不同的内存分配策略(如首次适配、最佳适配等)来优化内存使用<br> 缺点:<br> 1.维护开销:随着内存分配和释放的频繁进行,空闲列表的维护可能会带来一定的开销<br> 2.内存碎片:可能导致内存碎片,尤其是当空闲块和已分配块的大小频繁变动时<br> <br> 操作系统会根据具体的场景和需求选择最合适的内存分配策略和机制。空闲列表机制是其中一种常用的做法
操作系统中的空闲列表中的可用内存是连续的吗?<br>在操作系统的内存管理中,空闲列表中的可用内存不一定是连续的。内存的分配和释放会导致内存空间被分割成多个不连续的块,这些块可能会被链接成一个或多个空闲列表。<br>内存分配与碎片:<br>内存分配器在运行过程中,不同进程或程序请求不同大小的内存块,内存分配器需要从空闲列表中找到适合的内存块进行分配。当内存被释放时,这些内存块被归还给空闲列表。<br>如果这些释放的内存块不与现有的空闲块相邻,它们将成为新的、独立的空闲块,这样就会导致内存空间中出现不连续的空闲块,成为外部碎片。<br>内存分配算法:<br>不同的内存分配算法在管理空闲内存块时,处理碎片和保持内存连续性的方法有所不同:<br>1.首次适配(First-Fit):从空闲列表的开始位置查找第一个足够大的看空闲块进行分配。这种方法可能会在内存开始部分产生较多的碎片<br>2.最佳适配(Best-Fit):在空闲列表中查找最接近所需大小的空闲块进行分配。这种方法可能会产生更多的小碎片。<br>3.最差适配(Worst-Fit):在空闲列表中查找最大的空闲块进行分配。这种方法可以减少大的空闲块的数量,但可能会留下大的碎片<br>内存合并(Coalescing):<br>为了减少碎片,操作系统通常会在内存释放时进行内存块的合并。若相邻的两个或多个内存块都空闲,则将它们合并成一个更大的内存块,<br>从而减少外部碎片,增加可用的连续内存块<br>
为什么JVM不采用空闲列表的内存分配策略而是采用指针碰撞的形式?()<br> Java虚拟机(JVM)内存分配策略与操作系统内存分配策略的不同,主要是由以下几个因素决定的:<br> 1.内存管理的抽象层级不同<br> a.操作系统:操作系统负责管理武力内存,直接与硬件交互,需要处理多种复杂情况,如内存碎片、多进程/线程的内存需求等<br> b.JVM:JVM运行在操作系统之上,主要负责管理Java程序的运行时内存,它对内存的管理更加抽象化,并且通常不需要处理硬件级别的内存碎片问题<br> 2.内存分配的特点<br> a.空闲列表:适用于需要频繁分配和释放不同大小内存的场景,且物理内存可能存在碎片<br> b.指针碰撞(Bump-the-pointer):适用于对象大小相对一致且频繁创建和销毁的场景,如JVM中的对象分配<br> 3.JVM内存分配的具体考虑<br> a.效率:指针碰撞是一种非常高效的内存分配方式。在JVM中,当一个新的对象需要被分配时,只需要移动一下指针(分配指针),而不需要遍历整个空闲列表<br> 来查找合适的内存块。这大大减少了内存分配的开销<br> b.内存连续性:碰撞指针可以保证分配的内存是连续的,这对于提高缓存命中率有好处,因为连续的内存访问往往能更好地利用CPU缓存。<br> c.内存碎片:由于JVM通常会分配和释放大量大小相似的对象,内存碎片问题不像在操作系统中那么严重。JVM通过垃圾回收(GC)来管理内存,可以在GC过程中<br> 重新整理内存,减少碎片<br> d.垃圾回收:JVM采用垃圾回收机制来自动管理内存。当对象不再被应用时,垃圾回收器会自动回收它们所占用的内存。这种方式与空闲列表中的内存分配策略相比,<br> 减少了手动释放内存的复杂性,并且可以通过不同的垃圾回收算法来优化内存使用<br> 4.JVM的内存模型<br> a.堆空间:JVM的堆空间时用于存储Java对象的地方。堆空间通常分为年轻代、老年代等,不同代的内存管理策略不同。年轻代通常采用复制算法,而老年代可能<br> 采用标记-清楚或标记-整理算法。这些算法与指针碰撞的内存分配策略更为契合<br> <br> 5.综上所述<br> a.JVM选择指针碰撞的内存分配策略,而不是空闲列表,是因为这种策略更符合JVM内存管理的需求,能够提供给更高的内存分配和回收效率,并且与JVM 的垃圾回收机制更为兼容
操作系统为什么不采用指针碰撞的机制进行内存分配()<br>操作系统不采用指针碰撞的机制进行内存分配,主要是因为操作系统的内存管理需要面对更复杂和多样化的环境。以下是一些关键原因:<br>1.多任务和多用户环境<br>操作系统:必须支持多个进程和线程的运行,每个进程或线程可能需要不同大小的内存,且分配和释放内存的时间点是随机的<br>指针碰撞:适用于单一连续内存分配的场景,不适合处理多任务环境下的复杂内存请求<br>2.内存碎片<br>操作系统:需要处理内存碎片问题,因为不同大小的内存块被分分配和释放后,内存中可能会留下无法被利用的小空闲块<br>指针碰撞:不擅长处理内存碎片,因为它假设内存分配是连续的,如果内存碎片严重,指针碰撞机制将无法有效工作<br>3.内存分配的灵活性<br>操作系统:需要能够分配任意大小的内存块以满足不同进程的需求<br>指针碰撞:通常需要一个连续的内存区域,并且当内存区域不足以容纳新分配的内存块时需要额外的机制来处理这种情况<br>4.物理内存和虚拟内存<br>操作系统:使用虚拟内存技术,将物理内存与虚拟内存地址映射起来,这使得内存分配更加复杂<br>指针碰撞:适用于简单的物理内存分配,不涉及复杂的地址映射<br>5.安全性和隔离性<br>操作系统:需要确保不同进程的内存是隔离的,防止一个进程访问或修改另一个进程的内存<br>指针碰撞:需要额外的机制来保证内存的安全性和隔离性<br>6.性能考量<br>操作系统:必须高效地管理内存以满足性能需求,这通常意味着需要一个能够快速响应的内存分配策略<br>指针碰撞:虽然分配速度快,但在多任务环境下,它可能导致内存利用率地下,因为它可能留下很多小的空闲内存块<br>7.系统调用和API<br>操作系统;提供了系统调用和API供应应用程序请求和释放内存,这些调用需要能够处理各种复杂的内存分配请求<br>指针碰撞:无法直接适应这些系统调用和API的需求<br>因此,操作系统通常采用空闲列表、位图、伙伴系统等更复杂的内存分配策略,这些策略能够更好地处理多任务、多用户环境下的内存分配和碎片问题,同时保持较高的内存利用率和系统性能
3.TLAB线程私有堆(新生代)<br>JVM里面如果用锁来控制对象内存的分配的话,会比较繁琐,它是堆中某一块私有内存区域,如果用完了再还给JVM,重新盛情一块更大的内存
4.PLAB(TLAB的老年代)
对象的内存布局<br>1.MarkWord:<br>32位机器下占4B,64位机器下占8B<br>2.类型指针:<br>开启指针压缩占4B,关闭:占8B<br>3.数组长度<br>如果是数组对象:4B,非数组对象的话是没有的<br>4.对象头中的对齐填充(数组对象才有)对象头尾部<br>5.实例数据:非静态属性数据<br>boolean:1B<br>byte:1B<br>char:Java(2B) C++(1B)<br>int:4B<br>float:4B<br>long:8B<br>double:8B<br>引用类型:开启指针压缩:4B 关闭8B<br>6.对齐填充区域中的对齐填充<br>跟jvm规范有关,所有的对象大小都必须能被8整除,也叫8字节对齐
对象头的布局结构<br>1.锁标志位(lock):区分锁状态,11表示对象待GC回收状态,只有最后两位锁标识11有效<br>2.biased_lock:是否偏向锁,由于无锁和偏向锁的锁标识都是01,没办法区分,这里引入一位的偏向锁标识<br>3.分代年龄(age):标识对象存活的次数,当该次数达到阈值的时候,对象就会转移到老年代<br>4.对象的hashcode:运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。当对象加锁后,计算的结果31位不够标识,在偏向锁、轻量锁、重量锁,hashcode会被转移到ObjectMonitor中<br>5.偏向锁的线程ID(JavaThread):偏向模式的时候,当某个线程持有对象的时候,对象这里就会被设置位该线程的ID,在后面的操作中,就无需再次进行尝试获取锁的动作<br>6.epoch:偏向锁在CAS锁操作的过程中,偏向性标识,标识对象更偏向哪个锁<br>7.ptr_to_lock_record:轻量级锁状态下,指向栈中所记录的指针。当锁获取是无竞争的时候,JVM使用原子操作,而不是OS Mutex互斥。这种技术称为轻量级锁定。在轻量级锁状态下,JVM通过CAS操作在对象的Mark Word中设置指向所记录的指针<br>8.ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器ObjectMonitor的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁升级到Monitor以管理等待的线程,在重量级锁的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针
计算对象大小<br>
没有属性的对象<br>MarkWord:(64位)8B<br>类型指针:(默认是开启指针压缩的)4B<br>由于8B+4B=12B,不够被8整除,所以对齐填充4B<br>对象大小:8B+4B+4B=16B
有属性的对象<br>Mark Word:8B<br>类型指针:4B<br>实例数据:4B+4B<br>对象大小
数组对象<br>Mark Word:8B<br>类型指针:注意这里的数组类型是int类型,也就是说它的OopDesc是TypeArrayOopDesc,4B<br>数组长度:4B<br>实例数据:4B+4B+4B<br>8B+4B+4B+4B+4B+4B=28B,还是不能被8整除,所以对齐填充需要补4B<br>8B+4B+4B+4B+4B+4B+4B=32B<br>
指针压缩<br>-XX:-UseCompressedOops指针压缩技术只有64位机器才有。jdk6以后引入的技术,默认是开启的
关闭指针压缩的情况下
通过HSDB用Memory Viewer查看该对象在内存中的分配地址发现类型指针占8字节,0x3其实是数组的长度,前面用一行来存储类型指针
开启指针压缩的情况下
通过HSDB用Memory Viewer查看该对象在内存中的分配地址发现类型指针占4个字节,开启之后,类型指针和数组长度放在一起了
上面的情况分析如下:<br>跟C/C++中的一个联合体有关联<br>union u {<br>int i; // 4B<br>void p; // 8B<br>}<br>该联合体所占大小由最大字节数决定<br>比如说类型指针:0xffffffff,数组长度为0x00000003,当开启指针压缩时,由于只使用了联合体中的4B,但是联合体本身还是占8B,于是就出现了4B的浪费情况,数组长度占4B,那么把两者拼在一起,就可以节省8B的开销,把数组长度填充到前4B里面,相当于0x0003ffff。<br>
为什么数组对象在关闭指针压缩的情况下有两段填充?<font color="#a23735">不仅要站在学习者的角度去思考问题,更要以设计者的角度去思考</font><br><br>1.在开启指针压缩的情况下:<br>类型指针0xffffffff 8B<br>数组长度:0xffff 4B<br>用一个8B就存储下了<br>2.那么在关闭指针压缩的情况下<br>类型指针0xffffffff 8B<br>数组长度: 0x0000ffff<br>由于数组长度只占4B,根据8字节对齐的规范,对于一行内存地址来说,这里就出现了4字节的空间,那么我们知道,数组长度的下一个数据区域就是实例数据,如果说实例数据中正好有一个int类型,那么ok,正好填补到前面的4个字节里面,那如果不是int类型呢?是char类型的呢?虽然说可以存进去,但是作为设计者如何知道这个数据区域存储的实例数据是char类型还是int类型呢?所以说,存进去的话,会比较麻烦,于是需要在对象头的尾部进行对齐填充<br>
指针压缩的底层原理?这个技术能被开发出来归根结底是来自"所有对象大小都必须能被8整除"这条规则。<br><font color="#a23735">8字节对齐的言外之意 8(1 000)</font><br>如图所示,现在有三个对象<br>test1:16B<br>test2:32B<br>test3:24B<br><br>test1的起始地址: 0B<br>test2的起始地址:16B<br>test3的起始地址:48B<br>他们分别对应的二进制:<br>test1: 0 000<br>test2:10 000<br>test3:110 000<br>可以看到后三位永远是0,那么JVM在存储的时候,就可以这样存储,向右移3位。>>3<br>也就是说只存储<br>test1: 0<br>test2: 10<br>test3: 110<br>然后在使用的时候可以左移3位, << 3,再把它还原回去<br>根本原因在于"8字节对齐"这条规则
堆内存的32G瓶颈从何而来?<br>在32位机器中,最大内存可以表示为4G,现在机器普遍已经是64位了,按理说,堆内存可以设置超常的大,为什么还会有32G这个瓶颈之说呢?<br>原因在于,2的32次方表示的内存为4G,在指针压缩使用时,可以将低位进行左移3位,可表示的最大内存也就是2的35次方=32G.
如何扩容,思路是什么?为什么是8字节对齐?<br>要突破32G内存瓶颈的话,需要改写8字节对齐这条规则,8字节对齐的话,瓶颈是32G,16字节对齐,瓶颈也会扩大一倍,变成64G.但是这样需要考虑内存的使用率,因为在对齐填充的时候,补充的都是空白地址,也就是说在突破瓶颈的同时,也会带来内存空间的浪费。<br>现在64位机器,并没有完全使用64位地址来表示,只使用了48位,剩下16位保留。也就是说,64位机器上最大使用的内存是2的48次方,原因在于CPU还没有强大到能处理这么大的内存,受制于CPU的算力<br><br>例如:<br>8字节对齐:17B+7B=24B<br>16字节对齐:17B +15B= 32B(内存的浪费可能更严重)<br>另外还有一部分原因是:<br>32G的内存,那么OOM的Dump文件将会变得非常大,普通机器将没法分析<br><br>对于一个正常的GC来说,它的频率应该是<br>5分钟一次YGC<br>1天一次FullGC<br>内存大了,虽然发生FullGC的频率小了,但是单次GC花费的时间更长了
JVM调优<br>为什么调优?<br>调优的顺序:避免OOM>FullGC>YGC<br>1.避免OOM,<br>2.尽可能减少FullGC,FullGC会引发STW(Stop The World)<br>到底调什么?<br>1.在项目部署到线上之前,基于可能的并发量进行预估调优<br>2.,在项目运行过程中,部署监控收集性能数据,平时分析日志进行调优<br>3.线上出现OOM,进行问题排查与调优
实战:亿级流量系统实战<br>1.如果每个用户平均访问20个商品详情页,那访客数约定于500w(一亿/20)<br>2.如果按转化率10%来算,那日均订单约等于50w(500w * 10%)<br>3.如果30%的订单是在秒杀前两分钟完成的,那么每秒产生1200笔订单(50w*30%/120s)<br>4.订单支付又涉及到发起支付流程、物流、优惠券、推荐、积分等环节,导致产生大量对象,这里我们假设整个支付流程生成的对象为20K,那么每秒在Eden区生成的对象约等于20M(1200笔 * 20K)<br>5.在生产环境中,订单模块还涉及到百万商家查询订单、改价、包邮、发货等其他操作,又会产生大量对象,我们放大10倍,即每秒在Eden区生成的对象约等于200M(其实这里就是在大并发时刻可以考虑服务降级的地方,架构其实就是取舍)<br>假设响应一个请求的时间为3s(包括付款、扣减库存)<br>这里的假设数据都是大部分电商系统的通用概率,是有一定代表性的.如果你作为这个系统的架构师,面对这样的场景,你会如何做JVM调优呢?即将运行该系统的JVM堆区设置成多大呢?<br><br>分析:<br>1.每秒产生200M对象,Eden区的大小为2.2G,相当于11s就会触发YGC(11 x 200 = 2200M对象),由于假设一个请求响应时间为3s,所以发生GC的时候,会有600M(200M * 3 = 600M)对象回收不掉。GC回收的是标记不到的对象,而S0、S1只有270M,放不下这600M对象,进而触发老年代空间担保机制,放入老年代。对于老年代而言,大小为5400M->9次YGC->触发一次FGC.9x11=99s,触发一次FC,这样的频率8G内存不适合的<br>2.怎么做调优?加内存?怎么加?<br>S0、S1的内存区域要大于600M,这样,假设S0/S1都为600M,Eden区为4800M(600 x 8 = 4800),约等于6G,那老年代为12G(6G x 2 = 12G),内存加起来的话,12G+6G=18G,但是内存一般都取2的n次幂,所以取16G即可。<br><br>如果不调优,则会出现一直触发FGC,又回收不到可用的内存,进而导致JVM发生崩溃
1.堆到底设置成多大比较合适?堆越大越好吗?<br>如果堆设置的很小-> GC频率可能很高,GC时间也比较短<br>设置的很大->GC频率会低,但是GC的时长却增加了<br>调优不是一蹴而就的,需要逐步调整,并观察JVM的GC情况<br>2.什么样的系统可以进行JVM调优?<br>首先要区分出是OLAP(在线分析)系统还是OLTP(在线事务查询)系统,如果是OLAP(在线分析)系统,是没有多大调优空间的,因为一次性要查询大量对象,这个是没有办法做调优的,只能加大内存<br>3.正常GC单次时长100ms,前端和后端的平衡,不能让前端感到明显卡顿
7.OOM与调优
1.方法区
```java<br>import net.sf.cglib.proxy.Enhancer;<br>import net.sf.cglib.proxy.MethodInterceptor;<br>import net.sf.cglib.proxy.MethodProxy;<br><br>import java.lang.reflect.Method;<br><br>public class MetaspaceOverFlowTest {<br><br> /**<br> * 模拟CGLIB向元空间写入数据<br> */<br> public static void main(String[] args) {<br> while (true) {<br> try {<br> Thread.sleep(10);<br> } catch (InterruptedException e) {<br> throw new RuntimeException(e);<br> }<br><br> Enhancer enhancer = new Enhancer();<br><br> enhancer.setSuperclass(MetaspaceOverFlowTest.class);<br> enhancer.setUseCache(false);<br> enhancer.setCallback(new MethodInterceptor() {<br> @Override<br> public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {<br> return methodProxy.invokeSuper(o, args);<br> }<br> });<br><br> System.out.println("running ....");<br><br> enhancer.create();<br> }<br> }<br>}<br>```<br>模拟OOM
查看方法区使用情况<br>在配置Java环境的情况下,直接打开命令行输入jvisualvm命令即可打开<br>```bash<br>C:\Users\87766>jvisualvm<br>```<br>然后在左边找到我们的程序,右边是查看图形
查看GC情况需要安装一个插件Visual GC,左上方工具栏安装即可
GC日志<br>[GC (Metadata GC Threshold) [PSYoungGen: 9003K->672K(64512K)] 16030K->9067K(236544K), 0.0038284 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] <br>[Full GC (Metadata GC Threshold) [PSYoungGen: 672K->0K(64512K)] [ParOldGen: 8395K->8858K(74752K)] 9067K->8858K(139264K), [Metaspace: 20138K->20138K(32768K)], 0.0648738 secs] [Times: user=0.02 sys=0.00, real=0.07 secs]<br>
调优参数<br>```bash<br>-XX:MetaspaceSize=20M -XX:MaxMetaspaceSize=20M -XX:+PrintGCDetails<br>```<br>原则:最大、最小设置成一样大,避免因内存分配不足而引发扩容<br>程序运行起来后,通过VisualVM、Arthas查看占用了多少内存,向上调优,预留20%以上的空间
2.堆区
```java<br>public class HeapOverFlowTest1 {<br><br> int[] intArr = new int[10];<br><br> public static void main(String[] args) {<br> List<HeapOverFlowTest1> list = new ArrayList<HeapOverFlowTest1>();<br><br> for (;;) {<br> try {<br> Thread.sleep(1);<br> } catch (InterruptedException e) {<br> e.printStackTrace();<br> }<br><br> list.add(new HeapOverFlowTest1());<br> }<br><br> }<br>}<br>```<br>模拟OOM
调优vm参数<br>```java<br>-Xms10m -Xmx10m -XX:+PrintGCDetails<br>```
jvisualvm查看gc情况
gc日志<br>```bash<br>[GC (Allocation Failure) [PSYoungGen: 1120K->469K(2048K)] 7372K->6786K(9216K), 0.0021594 secs] [Times: user=0.00 sys=0.02, real=0.01 secs] <br>[GC类型(GC原因)[新生代收集器:gc前新生代的内存使用情况->gc后新生代的内存使用情况(新生代总内存)]gc前堆内存的使用情况->gc后堆内存的使用情况(堆总内存), gc耗时][TImes:gc阶段用户空间耗时 gc阶段内核空间耗时,gc阶段实际耗时]<br><br>[Full GC (Ergonomics) [PSYoungGen: 469K->0K(2048K)] [ParOldGen: 6316K->5603K(7168K)] 6786K->5603K(9216K), [Metaspace: 9480K->9410K(1058816K)], 0.0488684 secs] [Times: user=0.08 sys=0.02, real=0.05 secs]<br>[GC类型(GC原因)[新生代垃圾收集器:gc前新生代的内存使用情况->gc后新生代的内存使用情况(新生代总内存)][老年代垃圾收集器:gc前老年代的内存使用情况->gc后老年代的内存使用情况(老年代总内存)]gc前堆内存的使用情况-> gc后堆内存的使用情况(堆总内存),[Metaspace:gc前元空间的内存使用情况-> gc后元空间的内存使用情况(元空间总内存)], gc耗时][Times:gc阶段用户空间耗时 gc阶段内核空间耗时,gc阶段实际耗时]<br>```<br>
调优原则:<br>1.预留30%以上的空间<br>2.周期性看日志,重点关注Full GC频率
3.虚拟机栈<br>
```java<br>public class StackOverFlowTest {<br><br> private int val = 0;<br><br> public void test() {<br> val++;<br><br> test();<br> }<br><br> public static void main(String[] args) {<br> StackOverFlowTest test = new StackOverFlowTest();<br><br> try {<br> test.test();<br> } catch (Throwable e) {<br> e.printStackTrace();<br><br> System.out.println(test.val);<br> }<br> }<br>}<br>```<br>模拟OOM<br>
调优参数<br>-Xss200k 默认1M<br>
4.直接内存<br>JVM进程的堆与运行时数据区平行<br>1.Unsafe.allocateMemory<br>2.ByteBuf.allocateDirect(NIO)<br><br>怎么调优?不调优会怎样?(默认是不受限制)
模拟OOM,可以看到触发了操作系统的SIGKILL信号机制(signal 9:SIGKILL),但是这个日志怎么查看呢
如何查看Linux内核日志呢?<br>```bash<br>dmesg -T<br>```
5.调优参数类型<br>1.KV类型:-XX:MetaspaceSize=10M<br>2.boolean类型:-XX:+UseCompressedOops<br>3.简写类型:-Xms10m
6.工具如jps<br>该命令是纯Java编写的
-q:只显示Java进程的ID
-m:输出Java进程的ID + main函数所在类的名字 + 传递给main函数的参数
-l:输出Java进程的ID+main函数所在类的全限定名(包名+类名)
-v:输出Java进程的ID+main函数所在类的名称+传递给JVM的参数<br>应用:可以通过次方式快速查看JVM参数是否设置成功
jps源码的位置/openjdk/jdk/src/share/classes/sun/tools/jps
如何识别的Java进程?jps输出的信息全是Java进程的信息,是如何做到的?<br>Java进程在创建的时候,会生成相应的文件,进程相关的信息会写入该文件中。Windows下默认路径是:C:\Users\username\AppData\Local\Temp\hsperfdata_username<br>
Ubuntu环境下,它的路径是/tmp/hsperfdata_username
PerfData文件<br>1.文件创建<br>每启动一个Java进程,/tmp/hsperfdata_username就会生成进程号的一个文件,这个文件是一个内存映射文件。有时候不会生成,受参数的影响,<br>-XX:-/+UsePerfData默认是开启的,它是通过attach到Java进程中去,它属于寄生在Java进程当中,可以读取Java进程的内存。<br>-XX:-/+PerfDisableSharedMem(禁用共享内存)默认是关闭的,即支持内存共享。如果金庸了,依赖于PerfData文件的工具就无法正常进行了<br>2.文件删除<br>正常情况下:默认情况下随Java进程的结束而销毁<br>非正常退出:下一次去读目录的时候会检测进程是否存在, 用kill -0 去检测进程是否存活,不存货就会删除该进程号文件,不然就会留下垃圾文件<br>3.文件更新<br>-XX:PerfDataSamplingInterval=50,即内存与PerfData文件的数据延迟为50ms<br>
查看PerfData参数
7.JVM异常退出
OOM killer<br>dmesg -T 日志查看内核日志
堆OOM<br>会生成日志,分配内存new的执行流程<br>
元空间OOM<br>会生成日志,解析类、类加载器、动态字节码 cglib
直接内存OOM<br>unsafe bytbuffer<br>都不会生成日志,JVM进程的堆(OS知道)
栈OOM,为什么没有听说过这个地方发生OOM<br>开发阶段就会知道栈帧是否会发生OOM了
CPU占用过高如何排查?<br>1.定位到占用CPU最高的进程<br>2.定位到目前占用CPU最高的线程ID<br>top -H -p pid<br>将线程ID由十进制转换为十六进制<br>3.定位线程<br>jstack pid | grep 十六进制线程id -A 300
8.垃圾回收算法
Java程序在运行过程中会产生大量的对象,但是内存大小是有限的,如果光用而不释放,那内存迟早被耗尽。如C/C++程序,需要程序员手动释放内存,Java则不需要,是由垃圾回收期去自动回收。垃圾回收器回收内存至少需要做两件事情:标记垃圾、回收垃圾。于是诞生了很多算法
垃圾判断算法之引用计数算法<br>最简单的垃圾判断算法。在对象中添加一个属性用于标记对象被应用的次数,每多一个其他对象引用,计数+1,当引用失效时,计数-1,如果计数=0,表示没有其他对象引用,就可以被回收。这个算法无法解决循环依赖的问题。像Redis中就使用了这样的算法,Netty中的ByteBuffer也是如此,Python中。<br>在该算法中,没有其他对应引用A对象和B对象,但是AB对象之间存在着互相引用,以致于垃圾收集器无法回收
垃圾判断算法之可达性分析算法<br>通过一系列被称为"GC Roots"的根对象作为其实节点集,从这些节点开始,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象时不可用的。如图所示,绿色对象虽然互相有关联,但是他们到<br>GC Roots是不可达的,所以他们将会被判断为是可回收的对象<br><br>哪些对象可以作为GC Roots呢?在Java语言中,可作为GC Roots的对象包括下面几种:<br>1.虚拟机栈(栈帧中的本地变量表)中引用的对象<br> 2.方法区中类静态属性引用的对象<br>3.方法区中常量引用的对象<br>4.本地方法栈中JNI(即一般说的Native方法)引用的对象<br><br>1.所有Java线程当前活跃的栈帧里指向GC堆里的对象的引用;换句话说,当前所有正在被调用的方法的引用类型的参数/局部变量/临时值<br>2.VM的一些静态数据结构里指向GC堆里的对象的引用,例如说Hotspot VM里的Universe里由很多这样的引用<br>3.JNI handles,包括global handles和local handles<br>4.所有当前被加载的Java类<br>5.Java类的引用类型静态常量<br>6.Java类的运行时常量池里的引用类型常量(String或Class类型)<br>7.String常量池(StringTable)里的引用
引用类型<br>无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与"引用"有关。在JDK1.2以前,Java中的引用的定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。这种定义很纯粹,但是太过于狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态,对于如何描述一些"食之无味,弃之可惜"的对象就显得无能为力。我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存之中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。<br>在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。<br>1.强引用<br>是指在程序代码之中普遍存在的,类似"Object obj = new Object()"这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象<br>2.软引用<br>是用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK1.2之后,提供了SoftReference类来实现软引用<br>3.弱引用<br>也是用来描述非必须对象的,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2之后,提供了WeakReference类来实现弱引用,如ThreadLocal<br>4.虚引用<br>也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference类来实现虚引用
对象的生存还是死亡<br>即使在可达性分析算法种不可达的对象,也并非是"非死不可"的,这时候他们暂时处于"缓刑"阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为"没有必要执行"<br><br>如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的Finalizer线程去执行它,这里所谓的"执行"是指虚拟机会触发这个方法,但并不会承诺会等待它运行结束。这样做的原因是,如果一个对象在finalize()方法种执行缓慢,或者发生了死循环(更极端情况),将很可能导致F-Queue队列种其他对象永久处于等待,甚至导致整个内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将会对F-Queue种的对象进行第二次小规模的标记,如果对象要在finalize()种成功拯救自己——只要重写与引用链上的任何一个对象建立关联即可,比如把自己(this关键字)复制给某个类变量或者对象的成员变量,那在第二次标记是它将被移除"即将回收"的集合;如果对象这时候还没有逃脱。那么基本上它就真被回收了
内存池<br>Memory Pool<br>内存池,如果拿运行时数据区域类比的话 => 它就是JVM内存模型管理器<br>Memory Chunk<br>内存块 => 堆 方法区<br>Memory Cell<br>细胞 => 一个对象对应多个Cell<br>为什么要有内存池?<br>对象的频繁操作会涉及到用户态和内核态的切换。核心:避免频繁调用操作系统API去向操作系统分配内存、释放内存<br>技术是没有绝对的,只有优点的技术。解决了一些问题,又诞生了另一些问题。<br><br>在OS中,随着我们打开的进程越来越多,内存空间也变得越来越紧张,对于已经打开的内存,OS系统是不会回收的,那么OS是怎么做的呢?OS会进行类似LRU的操作,把不经常用的内存导入到硬盘空间,swap空间,但也不会无限导入,超过了swap空间,就会触发OS的OOM Killer机制。那么如果进程突然切换了,会触发缺页异常,如果在硬盘上,就导入到物理内存中,这也就是垃圾收集器的诞生背景,因为这块内存是JVM自己控制的,所以操作系统没法帮我们做,思维要严谨<br><br>MAC长时间不用,硬盘空间占用会非常多,把内存置换到物理硬盘上
垃圾回收算法<br>由于JVM要对自己管理的对象进行回收,于是就诞生了不同的垃圾回收算法.<br>
标记-清除算法<br>最基础的收集算法是"标记-清除"(Mark-Sweep)算法,如同它的名字一样,算法分为"标记"和"清楚"两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,之所以说它是最基础的收集算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。<br>它的主要不足有两个:<br>1.效率问题,标记和清楚两个过程的效率都不高;<br>2.空间问题,标记清楚之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
复制算法<br>为了解决效率问题,一种称为"复制"(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一般,未免太高了了一点。<br>现代的商业虚拟机都采用这种收集算法来回收新生代,IBM公司的专门研究表明,新生代的对象98%是"朝生夕死"的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor.最后清理掉Eden和刚才用过的Survivor空间。HoSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存会被浪费.当然98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion).<br>内存的分配担保机制就好比我们去银行借款,如果我们信誉很好,在98%的情况下东鞥按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有风险了。内存的分配担保也一样,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代
标记-整理算法<br>复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的时,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。因为没有多余的内存区域为老年代做担保,即便有,仍然需要有一块区域考虑到不能担保的情况。<br>根据老年代的特点,有人提出了另外一种"标记-整理"(Mark-Compact)算法,标记过程仍然与"标记-清楚"算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界意外的内存。<br><br>缺点:内存整理是CPU密集型的,比较耗费CPU
内存整理涉及到(排序+合并),那么需要遍历碎片。<br>1.在合并的时候排序<br>2.先排序再合并<br><br>碎片会存在哪些情况<br>1.有序<br>2.无序<br><br>思路:做两次遍历(jvm也是这样做的)<br>向前排序一次,比我小的内存地址往前移动<br>向后排序一次,比我大的内存地址往后移动
移动的对象为什么还可以访问?<br>因为对象的引用不是写死的,而是动态计算出来的。<br>1.静态存储 写死的,那么每轮GC都需要依赖中间数据结构来存储新地址,工作量会比较大<br>2.动态计算, 动态地址是怎么计算的?<br><br>计算规则:<br>1.找到内存块的起始位置 + 数据块的起始地址 * 8字节对齐(对应的Hotspot源码中是HeapWord这个结构)<br>get_data() + get_start() * get_align_size()<br><br>eg:如图所示:<br>0 + 3 * 8 = 24
分代收集算法<br>当前商业虚拟机的垃圾收集都采用"分代收集"(Generational Collection)算法。这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把Java分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外对他进行分配担保,就必须使用"标记-清除"或"标记-整理"算法来进行回收
三色标记与读写屏障<br>所有的垃圾回收算法都要经历标记阶段。如果GC线程在标记的时候暂停所有用户线程(STW),那就没三色标记什么事儿了,但是这样会有一个问题,用户线程需要等到GC线程标记完才能运行,给用户的感觉就是很卡,用户体验很差。<br>现在主流的垃圾收集器都支持并发标记。什么是并发标记呢?就是标记的时候不暂停或少暂停用户线程,一起运行。这势必会带来三个问题:多标、少标、漏标。垃圾收集器是如何解决这个问题的呢?三色标记+读写屏障
三色标记<br>把遍历对象过程中遇到的对象,按照"是否访问过"这个条件标记成三种颜色<br>1.白色:尚未访问过<br>2.黑色:本对象已访问过,而且本对象引用到的其他对象也全部访问过了<br>3.灰色对象:本对象已访问过,但是本对象引用到的其他对象尚未全部访问完。全部访问完,会转换为灰色<br><br><br>为什么新创建的对象默认是黑色?不能是灰色、白色 <br>不可能是灰色<br>黑色:本轮GC不管<br>白色:本来GC要管,尚未访问过,那么标记阶段将一直持续,直至用户程序不再创建新对象为止<br><br>经过一轮三色标记后,对象的颜色是何时还原的?<br>在对象移动之后,就会设置成无色
多标 浮动垃圾<br>GC线程已经标记了B,此时用户代码中A断开了对B的引用,但此时B已经被标记成了灰色,本轮GC不会被回收,这就是所谓的多标,多标的对象即成为浮动垃圾,躲过了本次GC.多标对程序逻辑是没有影响的,唯一的影响是该回收的对象躲过了一次GC,造成了些许的内存浪费
少标 浮动垃圾<br>并发标记开始后创建的对象,都视为黑色,本轮GC不清除<br>这里面有的对象用完就变成垃圾了,就可以销毁了,这部分对象即少标环境中的浮动垃圾<br><br><br>三色标记解决的是开始垃圾收集期间数据的变动<br>1.新创建的引用<br>2.已有的引用间的关系变动,在漏标问题中,可能出现空指针异常,<br>2.1 CMS 重新标记(增量更新) G1重新标记(原始快照)<br>3.如果执行完重新标记之后,又需要回到这些新创建的白色对象的初始标记,标记阶段将永远不会结束,如果频繁在创建对象
漏标问题 程序会出错<br>漏标是如何产生的呢? GC把B标记玩,准备标记B引用的对象,这时用户线程执行代码,代码中断开了B对D的引用,改为A对D的引用,但是A已经被标记成黑色,不会再次扫描A,而D还是白色,执行垃圾回收逻辑的时候,D会被回收,程序就会报空指针异常了<br><br>代码表示<br>B.D = null<br>A.D = ref;<br>漏标问题是如何产生的?<br>条件一:灰色对象 断开了白色对象的引用;即灰色对象原来的成员变量的引用发生了变化<br>条件二:黑色对象 重新引用了该白色对象;即黑色对象成员变量增加了新的引用<br>
1.读屏障+重新标记<br>在建立A对D的引用时将D作为白色或灰色对象记录下来,并发标记结束后STW,然后重新标记由D类似的对象组成的集合<br>重新标记环节一定要STW,不然标记就没完没了了
2.写屏障+增量更新(IU)<br>这种方式解决的是条件二,即通过写屏障记录下更新,具体做法如下:<br>对象A对D的引用关系建立时,将D加入待扫描的集合中等待扫描<br><br>这种方式强调的是引用关系的新增对象<br>黑色对白色的引用建立,增量更新,更新以后记录
3.写屏障+原始快照(STAB)<br>这种方式解决的是条件一,带来的结果是依然能够标记到D,具体做法如下:<br>对象B的引用关系变动的时候,即给B对象中的某个属性赋值时,将之前的引用关系记录下来,标记的时候,扫描旧的对象图,这个旧的对象图即原始快照<br><br>这种方式强调的是引用关系的删除对象<br>灰色对白色的引用断开 原始快照,断开之前记录
4.实际应用<br>CMS:写屏障+ 增量更新(效果不是很理想)<br>G1:写屏障 + STAB<br><br>最终标记阶段需要STW
读写屏障(有点像Spring的AOP)<br>1.读屏障(即在读前增加屏障做点事情)<br>读屏障()<br>读操作<br>2.写屏障(即写的前后增加屏障做点事情)<br>写前屏障()<br>写操作<br>写后屏障
记忆集(Remembered Set)、卡表(Card Table)<br>我们知道在G1垃圾收集器中,它是把Java堆分为多个Region,那么垃圾收集是否就真的能以Region为单位进行了?听起来顺理成章,再仔细想想就很容易发现问题所在:Region不可能是鼓励的。一个对象分配在某个Region中,它并非只能被本Region中的其他对象所引用,而是可以与整个Java堆任意的对象发生引用关系。那在做可达性分析确定对象是否存活的时候,岂不是还得扫描整个Java堆才能保证准确性?这个问题起始并非在G1中才有,只是在G1中更加突出。在CMS垃圾收集器中,也会存在这样的引用关系:新生代->新生代(没问题,对象要么都存活要么都死亡)、新生代->老年代(也是没问题的,无非新生代的对象存活的时间久点)、老年代->老年代(也没问题,同生共死)、老年代-> 新生代(有问题,万一新生代被回收了,会发生空指针异常)。那么如何解决这个问题的呢?<br>答案就是利用卡表<br>在G1收集器中,Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。G1中的每个Region都有一个与之对应的Remembered Set都有一个与之对应的Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remebered Set之中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。
具体的设计实现?<br>以G1为例,G1基于Region模型划分了2048个Region,每个Region是2M,总共是4G.也就是说要有2048张卡表,卡表中的每一页是512B.卡页中的1B管理4KB的内存(2M/512B=4KB),卡表:Region = 1:1.如果在4KB空间中如果存在老年代->新生代,卡页的位置标成1,卡页变成脏页。再扫描的时候只需要把4KB中的所有老年代对象拿出来扫描就解决了。当然也可以扩容每个Region的大小
9.垃圾收集器与GC日志
串行、并行、并发<br>串行:一个GC线程运行<br>并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。<br>并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上
STW(Stop The World)<br>即GC线程与用户线程无法并发运行,GC线程执行期间需要暂停用户线程。<br>比如:你妈给你打扫房间,需要把你从房间归纳出去,不然她一边打扫垃圾,你一边制造垃圾,没完没了了
垃圾收集器<br>目前JVM中的收集器有九种,了解5个,详解2个。因为并发、分区管理式的收集器才是未来的趋势。<br>注意:标记阶段标记的是存活对象,回收未被标记的对象。
Serial收集器<br>串行收集器,即GC线程与用户线程先后运行,即GC时需要STW(暂停所有用户线程),直至GC结束才恢复用户线程的运行。专注于收集年轻代,底层时赋值算法,相关参数:-XX:+UseSerialGC<br>
ParNew收集器<br>Serial收集器的多线程版本,唯一能与CMS收集器搭配使用的新生代收集器。<br>相关参数:<br>-XX:+UseConctMarkSweepGC:指定使用CMS后,会默认使用ParNew作为新生代收集器<br>-XX:+UseParNewGC:强制指定使用ParNew<br>-XX:ParallelGCThreads:指定垃圾收集的线程数量,ParNew默认开启的收集器线程与CPU的数量相同
Parallel收集器<br>关注吞吐量的收集器。吞吐量 = 运行用户代码时间/ (运行用户代码时间 + 垃圾收集时间)<br>相关参数:<br>-XX:MaxGCPauseMills:是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过设定值。不过大家不要异想天开地认为如果把这个参数的值设置得稍微小一点就能使得系统地垃圾收集速度变得更快,GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的。系统把新生代调小一些,收集300MB新生代肯定比收集500MB快吧,这也直接导致垃圾收集发生得更频繁游戏额,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次,每次停顿70毫秒。停顿的时间的确在下降,但吞吐量也降下来了。<br>-XX:GCTimeRatio:一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率。如果把此参数设置为19,那允许的最大GC时间就占总时间的5%(即1/(1+19)),默认值为99,就是允许最大1%(1/(1+99))的垃圾收集时间<br>-XX:+UseAdaptiveSizePolicy:一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、今生老年代年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomics)<br>
SerialOld<br>Serial收集器的老年代版本。基于标记-整理算法<br>有两个用途:<br>1.与Serial收集器、Parallel收集器搭配使用<br>2.作为CMS收集器的后备方案<br>
Parallel Old收集器<br>Parallel收集器的老年代版本。基于标记-整理算法
CMS收集器(并发)<br>CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java引用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户较好的体验,CMS收集器就非常符合这类应用的需求<br>聚焦低延迟。基于标记-清除算法实现。由于CMS收集器是并发收集器,即再运行阶段用户线程依然在运行,会产生对象,所以CMS收集器不能等到老年代满了才触发,而是要提前触发,这个阈值是92%。这个阈值可以通过参数-XX:CMSInitiatingOccupancyFraction设置<br><br>CMS的工作过程可以分为4个步骤<br>1.初始标记(CMS initial mark)<br>仍然需要STW,初始标记仅仅是标记以下GC Roots能直接关联到的对象,速度很快<br>2.并发标记(CMS concurrent mark)<br>不会STW.GC线程与用户线程并发运行.进行GC Roots Tracing的过程<br>3.重新标记(CMS remark)<br>会STW.为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间段<br>4.并发清除(CMS concurrent sweep)<br>GC线程与用户线程并发运行,清理未被标记到的对象。默认启动的回收线程数= (处理器核心数 + 3 ) / 4<br><br>CMS的缺点:<br>显然CMS收集器依然不是完美的,不然后面就不会出现G1、ZGC等<br>1.运行期间会与用户线程抢夺CPu资源。当然这是所有并发收集器的缺点<br>2.无法处理浮动垃圾(标记结束后创建的对象)<br>3.内存碎片
G1收集器(Garbage First, 低延迟)<br>G1收集器与之前的所有收集器都不一样,它将堆分成了一个一个Region,这些Region用的时候才被赋予角色:Eden、From、To、Humongous。一个Region只能是一个角色,不存在一个Region即是Eden又是From.如果这次Region内的对象被回收,那么它的角色可以跟之前的不一样。每个Region的大小可通过参数-XX:G1HeapRegionSize设置,取值范围是2-32M.一个对象的大小超过Region的一半则被认定为大对象,会用N个连续的Region来存储。Region个数在源码当中是一个常量2048<br>
G1收集器的特点:<br>1.并行与并发<br>G1能充分利用多CPU、多核环境下的硬件又是,使用多个CPU来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让java程序继续执行<br>2.分代收集<br>与其他收集器一样,分代概念在G1中依然得到保留。虽然G1可以不需要其他收集器配合就可以独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果<br>3.空间整合<br>与CMS的"标记-清除"算法不同,G1从整体来看是基于"标记-整理"算法实现的收集器,从局部(两个Region之间)上来看是基于"复制"算法实现的。但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC<br>4.可预测的停顿<br>这是G1相对于CMS的另一大又是,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java的垃圾收集器的特征了<br><br>缺点:<br>1.需要10%~20%的内存来存储G1收集器运行需要的数据,如cset、rset、卡表等<br>2.运行期间会与用户线程抢夺CPU资源。当然,这是所有并发收集器的缺点
G1运行示意图<br>1.初始标记<br>会STW,仅仅标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短。TAMS以上的值为新创建的对象,默认标记为存活对象,即多标<br>2.并发标记<br>耗时较长,GC线程与用户线程并发运行,从GC Roots能直接关联到的对象开始遍历整个对象图<br>从GC Root开始对堆中对象进行可达性分析,找出存活的对象,这解读那耗时较长,但可与用户线程并发执行<br>3.最终标记<br>会STW,遍历写屏障+SATB记录下的旧的引用对象图。<br>为了修正在并发标记期间因用户线程继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到RememberedSet中,这个阶段需要STW<br>4.筛选回收<br>需要STW.更新Region的统计数据,对各个Region的回收价值进行计算并排序,然后根据用户设置的期望暂停时间的期望值生成回收集。然后开始执行清除操作。将旧的Region中的存活对象移动到新的Region中,清理这个旧的Region。这个阶段需要STW.因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率
G1垃圾收集分类<br>YoungGC<br>YoungGC并不是说现有的Eden去放满了就会马上触发,G1会计算下现在Eden区回收大概需要多久时间,如果回收时间远远小于参数-XX:MaxGCPauseMills设定的值,那么增加年轻代的Region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数-XX:MaxGCPauseMills设定的值,那么就会触发YoungGC<br>MixedGC<br>不是FullGC,老年代的堆占有率达到参数(-XX:InitialHeapOccupancyPercent)设定的值则触发,回收所有的Young和部分Old(根据期望的GC停顿时间确定old区收集的优先顺序)以及大对象区,正常情况G1的垃圾收集是先做MixedGC,主要使用复制算法,需要把各个Region种存活的对象拷贝到别的Region里区,拷贝过程种如果发现没有足够的空Region能够承载拷贝对象就会触发一次FullGC<br>FullGC<br>停止系统程序,然后采用单线程进行标记、清理和压缩,好空闲出来一批Region来供下一次MixedGC使用,这个过程是非常耗时的。(Shenandoah优化成多线程收集了)
相关参数:<br>-XX:G1HeapRegionSize:设置Region的大小<br>-XX:MaxGCPauseMills:设置GC回收时允许的最大停顿时间(默认200ms)<br>-XX:+UseG1GC:开启G1<br>-XX:ParallelGCThreads:STW期间并行执行的GC线程数<br>-XX:TargetSurvivorRatio:Survivor区的填充容量(默认50%),Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个年龄对象)总和超过了Survivor区域的50%,此时就会把含年龄n(含)以上的对象都放入老年代<br>-XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值(默认45%),则执行新生代和老年代的混合收集(MixedGC).比如前面说的堆默认有2048个Region,如果有接近1000个Region都是老年代的Region,则可能就要触发MixedGC了<br>-XX:G1MixedGCLiveThresholdPercent(默认85%):region中的存户哦对象低于这个值时才会回收该Region,如果超过这个值,存活对象越多,回收的意义不大<br>-XX:G1MixedGCCountTarget:在一次回收过程中指定做几次筛选回收(默认8次),在最后一个筛选回收阶段可以回收一会儿,然后暂停回收,恢复系统运行,一会儿再开始回收,这样可以让系统不至于单词停顿时间过长
G1垃圾收集器优化建议<br>假设参数-XX:MaxGCPauseMills设置的值很大,导致系统运行很久,年轻代可能都占用了堆内存的60%了,此时才触发年轻代GC.那么存活下来的对象可能就会很多,此时就会导致Survivor区域放不下那么多的对象,就会进入老年代中。或者是你年轻代过后,存活下来的对象过多,导致进入Survivor区域后触发了动态年龄判定规则,达到了Survivor区域的50%,也会快速导致一些对象进入老年代。<br>所以这里核心还是在于调节-XX:MaxGCPauseMills这个参数的值,在保证它的年轻代GC别太频繁的同时,还得考虑GC过后的存活对象有多少,避免存活对象太多快速进入老年代,频繁触发mixed gc
ZGC收集器(Z Garbage Collector,全并发、超低延迟 10ms)<br>ZGC是一款JDK11中新加入的具有实验性质的低延迟垃圾收集器,ZGC可以说源自于Azul System公司开发的C4(Concurrent Continuously Compacting Collector)收集器<br><br><br>参考文章:https://wiki.openjdk.java.net/display/zgc/Main<br>http://cr.openjdk.java.net/~pliden/slides/ZGC-Jfokus-2018.pdf<br>
目标<br>1.支持TB量级的堆。一般生产环境的硬盘还没有上TB呢,这应该可以满足未来十年内,所有Java应用的需求了吧<br>2.最大GC停顿时间不超过10ms.目前一般线上环境运行良好的Java应用Minor GC停顿时间在10ms左右,Major GC一般都需要100ms以上(G1可以调节停顿时间,但是如果调的过低的话,反而会适得其反),之所以能做到这一点是因为它的停顿时间主要跟Root扫描有关,而Root数量和堆大小是没有关系的<br>3.奠定未来GC特性的基础<br>4.最糟糕的情况下吞吐量会降低15%。这都不是事,停顿时间足够优秀。至于吞吐量,通过扩容分分钟解决。另外,Oracle官方提到了它的最大优点是:它的停顿时间不会随着堆的增大而增长!也就是说,几十G堆的停顿时间是10ms一下,几百G甚至上T堆的停顿时间也是10ms一下
不分代(暂时)<br>单代,即ZGC[没有分代]。我们知道以前的垃圾回收器之所以分代,是因为源于"[大部分对象朝生夕死]"的假设,事实上大部分系统的对象分配行为也确实符合这个假设,那么为什么ZGC就不分代呢?因为分代实现起来麻烦,作者就先实现出一个比较简单的单代版本。后续会优化
ZGC的内存布局<br>ZGC收集器是一款基于Region内存布局的,暂时不设分代的。使用了<font color="#a23735">读屏障、颜色指针</font>等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。<br>ZGC的region可以具有如图所示的大、中、小三类容量:<br>1.小型Region(Small Region):容量固定为2MB,用户放置小于256KB的小对象(x < 256KB, x为对象大小)<br>2.中型Region(Medium Region):容量固定为32MB,用户放置大于等于256KB但小于4MB(256KB <= x < 4MB)<br>3.大型Region(Large region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象,每个大型Region中只会存放一个大对象,这也预示着虽然名字叫做"大型Region",但它的实际容量完全有可能小于中型Region,最小容量可低至4MB.大型Region在ZGC的实现中是不会被重分配的(重分配是ZGC的一种处理动作,用于复制对象的收集阶段),因为复制一个大对象的代价非常高昂。
NUMA-aware<br>NUMA对应的有UMA,UMA即Uniform Memory Access Architecture,NUMA就是Non Uniform Memory Access Architecture.UMA标识内存只有一块,所有的CPU都去访问这一块内存,那么就会存在竞争问题(争夺内存总线访问权),有金正就会有锁,有锁效率就会收到影响,而且CPU核心越多,竞争就越激烈。NUMA的话每个CPU对应有一块内存,且这块内存在主板上离这个CPU是最近的,每个CPU优先访问这块内存,那效率自然就提高了:<br>服务器的NUMA架构在中大型系统上一直非常盛行,也是高性能的解决方案,尤其在系统延迟方面表现都很优秀,ZGC是能自动感知NUMA架构并充分利用NUMA架构特性的
颜色指针<br>Colored Pointers,即颜色指针,如下图所示,ZGC的核心设计之一。以前的垃圾回收器的GC信息都保存在对象偷中,而ZGC的GC信息保存在指针中。<br>每个对象有一个64位指针,这64位被分为:<br>18Bit:预留给以后使用<br>1Bit:Finalizable标识,此位与并发引用处理有关,它表示这个对象只能通过finalizer才能访问<br>1Bit:Remapped标识,设置此位的值后,对象未指向relocation set中(relocation set标识需要GC的Region集合)<br>1Bit:Marked1标识<br>1B:Marked0标识,和上面的Marked1都是标记对象用于辅助GC<br>42位:对象的地址(所以它可以支持2^42 = 4T内存)<br><br>为什么有2个mark标记?<br>每一个GC周期开始时,会交换使用过的标记位,使上次GC周期中修正的已标记状态失效,所有引用都变成未标记。<br>GC周期1:使用mark 0,则周期结束后所有引用mark标记都会成为01<br>GC周期2:使用mark2,则期待的mark标记10,所有引用都能被重新标记<br><br>通过设置ZGC后对象指针分析我们可知,对象指针必须是64位,那么ZGC就无法支持32位操作系统,同样的也就无法支持压缩指针了(CompressedOops,压缩指针也是32位)。<br><br>颜色指针的三大优势:<br>1.一旦某个Region的存活对象被一走之后,这个Region立即就能够被释放和重用掉,而不必等待堆中所有指向该Region的引用都被修正后才能清理。这使得理论上只要还有一个空闲Region,ZGC就能完成收集<br>2.颜色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,ZGC只使用了读屏障<br>3.颜色指针具备强大的扩展性,它可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能
读屏障<br>之前的GC都是采用Write Barrier,这次ZGC采用了完全不同的方案读屏障,这个是ZGC一个非常重要的特性。在标记和移动对象的阶段,每次【从堆里对象的引用类型中读取一个指针】的时候,都需要加上一个Load Barrier.那么该如何理解它呢?看下面的代码,第一行代码我们尝试读取堆中的一个对象引用obj.fieldA并赋给引用o(fieldA也是一个对象时才会加上读屏障)。如果这时候对象在GC时被移动了,接下来JVM就会加上一个读屏障,这个屏障会把读出的指针更新到对象的新地址上,并且把堆里的这个指针"修正"到原本的字段里。这样就算GC把对象移动了,读屏障也会发现并修正指针,于是应用代码就永远都会持有更新后的有效指针,而且不需要STW.那么,JVM时如何判断对象被移动过呢?就是利用上面提到的颜色指针,如果指针是Bad Color,那么程序还不能往下执行,需要【slow path】,修正指针;如果指针是Good Color,那么正常往下执行即可:<br>```java<br>// Load Barrier<br>Object o = obj.fieldA; // Loading an object reference from heap<br><load barrier needed hear><br>Object p = o; // No barrier, not a load from heap<br>o.doSomething(): // No barrier, not a load from heap<br>int i = obj.fieldB; // No barrier, not an object reference<br>```<br>这个动作是不是非常像JDK并发中用到的CAS自旋?读取的值发现已经失效了,需要重新获取,而ZGC这里是之前持有的指针由于GC后失效了,需要通过读屏障修正指针。后面3行代码都不需要加读屏障:Object p = o 这行代码并没有从堆中读取数据;o.doSomething()也没有从堆中读取数据;obj.fieldB不是对象引用,而是原子类型。<br>正是因为Load Barrier的存在,所以会导致配置ZGC的应用的吞吐量会变低。官方的测试数据是需要多出额外4%的开销<br>```java<br>// Load Barrier<br>mov 0x20(%rax), %rbx // Object o = obj.fieldA;<br>test %rbx, (0x16)%r15 // Bad color ?<br>jnz slow_path // Yes -> Enter slow path and mark/relocalte/remap, ajust 0x20(%rax) and %rbx<br>```<br>那么判断对象是Bad Color还是Good Color的依据是什么呢?就是根据前面提到的Colored Pointers的4个颜色位。当加上读屏障时,根据对象指针中这4位的信息,就能知道当前对象是Bad/Good Color了。<br><br>PS:既然低42位指针可以支持4T内存,那么能否通过预约更多位给对象地址来达到更大内存的目的呢?答案肯定是不可以。因为目前主板地址总线最宽只有48bit,4位是颜色位,就只剩44位了,所以受限于目前的硬件,ZGC最大只能支持15T的内存,JDK13就把最大支持堆内存从4T扩大到16T
ZGC运作过程<br>ZGC的运作过程大致可划分为以下4个大的阶段:<br>1.并发标记(Concurrent Mark):与G1一样,并发标记是遍历对象图做可达性分析的阶段,它的初始标记(Mark Start)和最终标记(Mark End)也会出现短暂的停顿,与G1不同的是,ZGC的标记是在指针上而不是对象上进行的,标记阶段会更新染色指针中的Marked 0、Marked 1标志位<br>2.并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次回收过程要清理哪些Region,将这些Region组成重分配集(Relocation Set).ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。<br>3.并发重分配(Concurrent Relocate):重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问到了位于重分配集中的对象,这次访问将会被预置的内存屏障(读屏障)所截获,然后立即根据Region上的转发表记录将访问到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的"自愈(Self-Healing)"能力。<br>(ZGC的颜色指针因为"自愈(Self-Healing)"能力,所以只有第一次访问旧对象会变慢,一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于新对象的分配,但是转发表还得留着不能释放掉,因为可能还有访问在使用这个转发表)<br>4.并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但是ZGC中对象引用存在"自愈"功能,所以这个重映射操作并不是很迫切。ZGC很巧妙地把并发重映射要做地工作,合并到了下一次垃圾收集循环中地并发标记阶段里去完成,反正他们都是要遍历所有对象地,这样合并旧节省了一次遍历对象图的开销,一旦所有指针都被修正之后,原来记录新旧对象关系的转发表就可以释放掉了
ZGC存在的问题<br>ZGC最大的问题是浮动垃圾,ZGC的停顿时间在10m以下,但是ZGC的执行时间还是远远大于这个时间的。加入ZGC全过程需要执行10分钟,在这个期间由于对象分配速率很高,将创建大量的系对象,这些对象很难进入当次GC,所以,只能在下次GC的时候进行回收,这些只能等到下次GC才能回收的对象就是浮动垃圾.(ZGC没有分代概念,每次都需要进行全堆扫描,导致一些"朝生夕死"的对象没能及时地被回收)<br><br>解决方案<br>目前唯一的办法就是增大堆的容量,使得程序得到更多的喘息空间,但是这个也是一个治标不治本的方案。如果需要从根本上解决这个问题,还是需要引入分代收集,让新生对象都在一个专门的区域中创建,然后专门针对这个区域进行更频繁、更快的收集
ZGC是如何解决漏标问题的?<br>ZGC在并发阶段通过多种技术和机制来解决漏标问题,确保垃圾收集的准确性和一致性。漏标问题的出现通常是因为在并发<br>标记过程中,应用线程可能会修改对象引用,导致某些对象未被标记为存货。为了避免这种情况,ZGC采用了以下关键技术:<br><br>染色指针(Colored Poninters)<br>ZGC通过在对象引用指针中嵌入额外的标记信息(即颜色),将对象的状态直接编码到指针中。这些染色指针允许ZGC在访问<br>对象时立即知道改对象的标记状态<br>1.指针中的颜色信息:ZGC在64位的对象引用指针中嵌入了几位来存储颜色信息。这些颜色信息用于表示对象的状态,<br>例如是否已经被标记、是否正在移动、或者是新的对象等。<br>2.读屏障(Load Barrier):当应用线程访问一个新对象时,ZGC的读屏障会检查这个对象的染色指针,确保对象被正确标记或<br>处理。如果一个对象还未被标记但已经被应用,ZGC会通过读屏障触发相应的操作,防止漏标<br><br>读屏障(Load Barrier)<br>读屏障时一种在对象引用被读取时执行的特殊检查机制。ZGC在并发标记阶段使用读屏障来解决并发修改引用导致的漏标问题<br>1.作用:当应用线程试图访问一个对象时,读屏障会检查改对象的标记状态。如果该对象的标记状态不符合预期(例如对象违背标记为<br>存活),读屏障会将该对象标记为存货,确保它不会被垃圾回收<br>2.如何避免漏标:通过在每次访问对象时触发读屏障,ZGC可以捕捉到应用线程对引用的修改,确保即使对象的引用在标记过程中<br>被修改,也不会导致漏标<br><br>并发标记和颜色修正<br>ZGC结合染色指针和读屏障,在并发标记阶段通过以下步骤防止漏标<br>1.初始标记(Initial Mark):这个阶段是一个短暂的Stop-The-World(STW)暂停,标记从GC Roots可达的对象,确保基本的标记七点<br>2.并发标记(Concurrent Mark):在这一阶段,标记过程与应用线程并发进行。任何在标记过程中被访问的对象,都会通过读屏障检查和更新其标记状态<br>3.重新标记(Remark)::这个阶段是另一个短暂的STW暂停,用于处理在并发标记过程中遗漏的对象。这是最后一次确保所有存货对象都被正确标记<br>4.颜色修正(Color Correction):ZGC在标记阶段通过颜色修正机制确保对象的状态始终一致。这意味着如果某个对象在标记过程中(例如从未标记到已标记)<br>ZGC会立即进行颜色修正,避免漏标<br><br>并发重定位(Concurrent Relocation)<br>在对象的移动过程中,ZGC确保在移动过程中对象引用的所有访问都通过读屏障,从而防止漏标<br>1.自愈(Self-Healing)机制:ZGC使用自愈机制,即时在对象移动过程中,如果访问未完成标记的对象,读屏障仍然会确保这些对象被正确标记或处理<br><br>总结<br>ZGC通过颜色指针、读屏障、颜色修正和自愈机制等多种技术组合,成功解决了并发中的漏标问题。这些技术确保在并发标记阶段,无论应用线程<br>如何修改对象引用,所有存活的对象都能被正确标记,从而避免了垃圾收集过程中的任何不一致性。这使得ZGC能够在提供极低延迟的同时,仍然保证<br>垃圾收集的准确性和完整性
ZGC的Mark0和Mark1的作用是什么?<br>ZGC的Mark0和Mark1是ZGC并发标记阶段中的两个关键步骤,分别用于处理不同的任务,以确保垃圾回收的准确性和效率。这两个步骤的主要作用如下:<br><br>Mark0阶段<br>作用<br>1.Mark0是ZGC并发标记过程中的第一个阶段。它主要负责标记从GC Roots可达的对象,并初始化整个并发标记过程<br>2.在Mark0阶段,ZGC通过遍历GC Roots(静态变量、栈上的局部变量、寄存器中的引用等),将所有直接可达的对象标记为存活。这个阶段通常是短暂的Stop-The-World(STW)暂停<br>3.Mark0阶段的标记结果作为后续并发标记的基础。它确保了所有从GC Roots开始的对象都被正确标记,避免漏标<br>具体任务<br>1.标记从GC Roots可达的对象<br>2.初始化并发标记所需的数据结构<br>3.确保初始标记阶段不遗漏任何根对象的标记<br><br>Mark1阶段<br>作用<br>1.Mark1是ZGC并发标记过程中的另一个关键阶段,通常是并发标记的第二个阶段。与Mark0不同,Mark1主要负责处理在Mark0之后对象的引用变化,并确保这些对象能够正确标记<br>2.在Mark1阶段,ZGC会继续堆堆中的对象进行遍历和标记,以确保所有存活的对象(包括在Mark0之后新创建或新引用的对象)都能被正确标记为存活<br>具体任务<br>1.并发地遍历和标记对象图中的其余对象,这个阶段通常与应用线程并发执行<br>2.处理Mark0阶段后对象引用的变化,确保这些变化不会导致漏标<br>3.确保整个堆中的所有可达对象在标记过程中都能被正确标记<br><br>总结<br>在ZGC的并发标记过程中,Mark0和Mark1分别承担了不同但相互补充的任务:<br>1.Mark0主要负责从GC Roots开始的初始标记,确保垃圾回收有一个可靠的起点<br>2.Mark1则负责继续标记堆中的其余对象,确保在并发标记过程中,没有存活对象被漏标<br>这两个阶段共同作用,确保ZGC在进行垃圾回收时,能够准确地标记和回收内存,同时保持极低地暂停时间
ZGC是如何解决漏标问题的
CMS和G1中是采用Write Barrier来解决对象引用发生变化的,而ZGC是采用Load Barrier来解决的,通过在每次访问对象时触发读屏障,ZGC可以捕捉到应用线程引用的修改,确保即时对象的引用在标记过程中被修改,也不会导致漏标问题
ZGC参数设置<br>启用ZGC比较简单,设置JVM参数即可:-XX:+UnlockExperimentalVMOptions 【-XX:+UseZGC】。调优也并不难,因为ZGC调优参数并不多,远不像CMS那么复杂。它和G1一样,可以调优的参数都比较少,大部分工作JVM能很好的自动完成
ZGC触发时机<br>ZGC目前有4种机制触发GC:<br>1.定时触发:默认为不适用,可通过ZCollectionInterval参数配置<br>2.预热触发,最多三次,在堆内存达到10%、20%、30%时触发,主要是统计GC时间,为其他GC机制使用<br>3.分配速率,基于正态分布统计,计算内存99.9%可能的最大分配速率,以及此速率下内存将要耗尽的时间点<br>4.主动触发(默认开启,可通过ZProactive参数配置)距离上次GC堆内存增长10%,或超过5分钟时,对比距离上次GC的间隔时间(49 * 一次GC的最大持续时间),超过则触发
如何选择垃圾收集器<br>1.优先调整堆的大小让服务器自己来选择<br>2.如果内存小于100M,使用串行收集器<br>3.如果是单核,并且没有停顿时间的要求,串行或JVM自己选择<br>4.如果允许停顿时间超过1秒,选择并行或者JVM自己选<br>5.如果响应时间最重要,并且不能超过1秒,使用并发收集器<br>6.4G以下可以用parallel,4-8G可以用ParNew + CMS,8G以上可以用G1,几百G以上用ZGC
在并发垃圾收集器在第一次GC没有进行完,可以发起第二次GC吗?
CMS垃圾收集器<br>1.如果第一轮GC还没有完成,而此时由于用户线程的继续执行导致又触发了新的GC请求,那么第二轮GC不会立即执行,而是需要等待第一轮GC完成后才会开始<br>2.GC触发机制<br>当队中的老年代使用量达到一定阈值时,会触发CMS GC以回收内存。如果在一次CMS GC还没有完成时,用户线程继续分配对象,导致老年代再次达到触发阈值,理论上需要再次进行GC<br>2.CMS GC 重入性<br>CMS垃圾收集器本身并不支持"重入性",即它同时进行多次垃圾收集。CMS的设计中,一次GCGC需要完成它的所有阶段后,才能开始新的GC。<br>因此,如果在一次CMS GC还没完成时,用户线程的内存分配再次触发了GC请求,那么新的GC请求将会背延迟,直到当前的CMS GC完成<br>3.并发模式失败(Concurrent Mode Failure)<br>如果在CMS执行的过程中,用户线程分配内存速度过快,导致老年代空间不足,无法等待CMS完成,此时JVM会触发"并发模式失败"(Concurrent Mode Failure)<br>这种情况下,JVM会切换到一个单线程的"Searial Old" GC执行一次Stop-The-World(STW)全堆回收,以保证系统能够继续运行,Searial Old GC是一种较慢但确保回收的垃圾收集方式
G1垃圾收集器<br>G1收集器在一次GC尚未完成时,如果又触发了新的GC请求,第二次GC不会打断第一次GC,而是会在第一次GC完成后开始。这一点与CMS类似,但G1的优势在于它的设计更灵活,能够更好地控制GC暂停时间并减少进入Full GC的概率。
ZGC垃圾收集器<br>1.如果在ZGC正在进行一次垃圾回收时(例如正在进行并发标记或并发重定位),用户线程分配新对象的速度很快,导致需要再次进行垃圾回收,ZGC不会因为新的GC请求而停止当前的GC操作。相反,他会继续完成当前的GC操作,同事计划和开始新的GC周期<br>2.由于ZGC的设计理念是"全并发",因此它能够非常灵活地处理多个垃圾回收周期的重叠情况。新的GC周期可以与旧的GC周期重叠执行,而不会导致显著的暂停时间增加
为什么需要STW? 安全点、安全区域又是什么?<br>暂停线程 暂停所有有可能导致引用关系变动的线程<br>为什么要暂停? 如果引用关系一直在变的话,GC不干净<br><br>遍历线程,发起挂起信号。<br>主动式:JVM的实现方式,借助安全点实现<br>抢先式: 给每个线程发送暂停信号<br><br>在漏标问题中,对于引用发生变化的对象,它会被保存到OopMap里面记录<br>线程阻塞前需要更新OopMap,那么什么时候记录呢?
枚举根节点<br>从可达性分析中从GC Roots节点找引用链这个操作为例,可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,现在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,那么必然会消耗很多时间。另外,可达性分析对执行时间的敏感还体现在GC停顿上,因为这项工作必须在一个能确保一致性的快照中进行——这里"一致性"的意思是指在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对应引用关系还在不断变化的情况。该点不满足的话分析结果准确性就无法得到保证。这点是导致GC进行时必须停顿所有Java执行线程(Sun将这件事称为"Stop The World")的其中一个重要原因,即使时在号称(几乎)不会发生停顿的CMS收集器中,枚举根节点也是必须要停顿的。<br>由于目前的主流Java虚拟机使用的都是准确式GC,所以当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得知哪些地方存放着对象引用。在HotSpot的实现中,是使用一组称为OopMap的数据结构来达到这个目的的,在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC在扫描时就可以直接得知这些信息了。<br><br>例如<br>```bash<br>[Verified Entry Point]<br>0x026eb730: mov $eax,-0x8000(%esp)<br>....<br>0x026eb7a9: call 0x026e83e0; OopMap{ebx=Oop [16]=Oop off=142}<br>0x026eb7ae: push $0x83c5c18<br>0x026eb7b3: call 0x026eb7b8<br>0x026eb7b8: pusha<br>0x026eb7b9: call 0x0822bec0<br>0x026eb7be: hlt<br>```<br>上面代码时HotSpot Client VM生成的一段String.hashCode()方法的本地代码,可以看到在0x026eb7a9处的call指令有OopMap记录,它指明了EBX寄存器和栈中偏移量为16的内存区域处各有一个普通对象指针(Ordinary Object Pointer)的引用,有效范围为从call指令开始知道0x026eb730(指令流的起始位置)+142(OopMap记录的偏移量)=0x026eb7be,即hlt指令为止<br><br>准确式GC<br>早之前的Exact VM版本因为使用准确式内存管理(Exact Memory Management也可以叫Non-Conservative/Accurate Memory Management)而得名,即虚拟机可以知道内存中某个位置的数据具体是什么类型。比如内存中有一个32位的整数123456,它到底是一个reference类型指向123456的内存地址还是一个数值位123456的整数。虚拟机将有能力分辨出来,这样才能在GC的时候准确判断堆上的数据是否还可能被使用。由于使用了准确式内存管理,Exact VM可以摒弃以前Classic VM基于handler的对象查找方式(原因是进行GC后对象将可能会被移动位置。如果将地址为123456的对象移动到654321,在没有明确信息表明内存中哪些数据是reference的前提下,虚拟机是不敢把内存中所有为123456的值改成654321,所以要使用句柄来保持reference值的稳定),这样每次定位对象都少了一次间接查找的开销,提升执行性能
安全点(如果线程随便哪个位置都可以停下来,这个问题就会简单很多)<br>在OopMap的协助下,HotSpot可以快速且准确地完成GC Roots枚举,但一个很现实地问题随之而来:可能导致引用关系变化,或者说OopMap内容变化地指令非常多,如果为每一条指令都生成OopMap,那将需要大量的额外空间,这样GC的空间成本将会变得很高。<br>实际上,HotSpot也的确没有为每条指令都生成OopMap,前面已经提到,只是在"特定位置"记录了这些信息,这些位置称为安全点(Safepoint),即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。Safepoint的选定既不能太少以至于让GC等待时间太长,也不能过于频繁以至于过分增大运行时的负荷。所以,安全点的选定基本上是以程序"是否具有让程序长时间执行的特征"为标准进行选定的——因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,"长时间执行"的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等。所以具有这些功能的指令才会产生Safepoint.<br><br>对于Safepoint.另一个需要考虑的问题是如何在GC发生时让线程(这里不包括执行JNI调用的线程)都"跑"到最近的安全点上再停顿下来。这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。其中抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它"跑"到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。而主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志位真时就自己中断挂起,轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。<br><br>例如<br>```bash<br>0x01b6d627:call 0x01b2b210; OopMap{[60]=OOp off=460}<br>0x01b6d62c:nop<br>0x01b6d62d:test %eax,0x160100<br>0x01b6d633:mov 0x50(%esp),%esi<br>0x01b6d637:cmp %eax,%esi<br>```<br>下面代码中的test指令是HotSpot生成的轮询指令,当需要暂停线程时,虚拟机把0下0x60100的内存页设置为不可读,线程执行到test指令时就会产生一个自陷异常信号,在预先注册的异常处理器中暂停线程实现等待,这样一条指令便完成安全点轮询和触发线程中断<br><br><br>安全点同时解决了STW(暂停的位置)和更新OopMap(更新对象引用关系的位置)的问题<br><br>从Linux层面来说,可以调用API来暂停线程,代码量会比较大,JIT还要把它转成硬编码,难度也大。安全点现在的解决方案:<br>通过内存中断实现,内存页memory page 可读可写可执行,JIT需要这几个属性,如果想要安全性更高,需要把可执行禁掉。Mac上JIT是不工作的,在合适安全点的地方插入一段代码 test %eax,os_poling_page.安全点到达实际就是用户执行到test指令的实际,这条指令会改变状态寄存器状态,使得内存页变得不可读不可写,触发段异常,SIGSEGV内核捕获之后暂停线程
安全区域<br>使用Safepoint似乎已经完美地解决了如何进入GC的问题,但实际情况却并不一定,Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint.但是,程序"不执行"的时候呢?所谓的程序不执行就是没有分配CPU时间,典型的例子就是线程处于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求,"走"到安全的地方去中断挂起,JVM也显然不太可能等待线程重新被分配CPU时间。对于这种情况,就需要安全区域(Safe Region)来解决。安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始的GC都是安全的。我们也可以把Safe Region看作是被扩展了的Safepoint。<br>在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待知道收到可以安全离开Safe Region的信号为止。
10.执行引擎、JIT、逃逸分析
JVM中的执行引擎是什么?
在Java虚拟机(JVM)中,执行引擎(Execution Engine)是负责执行Java字节码的核心组件。执行引擎的作用是将Java字节码转换成计算机可以执行的机器码,并实际执行这些机器码。以下是JVM执行引擎的主要职责和组成部分:<br><br>主要职责:<br>1.加载和验证字节码:执行引擎确保加载的类文件符合JVM规范,并进行验证<br>2.执行字节码:执行引擎逐条执行Java字节码指令<br>3.优化执行:在运行时,执行引擎可能会对字节码进行优化以提高性能<br>4.内存管理:执行引擎负责管理JVM运行时的内存,包括堆(Heap)、栈(Stacks)、方法区(Method Area)等<br><br>执行引擎的组成部分:<br>解释器(Interpreter):<br>1.解释器是执行引擎的一部分,它逐条读取和执行Java字节码<br>2.解释执行的特点是启动速度块,但执行速度相对较慢,因为它需要逐条解释和执行字节码<br><br>即时编译器(Just-In-Time Compiler, JIT)<br>1.JIT编译器将Java字节码编译成本地机器码,这样可以直接在硬件上执行,从而提高执行效率<br>2.JIT编译器在运行时进行编译,可以针对程序的运行特性进行优化<br><br>垃圾回收器(Garbage Collector, GC)<br>1.虽然垃圾回收器主要负责内存管理,但它也是执行引擎的一部分,因为它在执行过程中负责清理不再使用的对象<br><br>本地方法接口(Natvie Method Interface, JNI)<br>1.JNI允许Java代码调用其他语言编写的本地(Native)方法,这些方法通常用C/C++编写<br><br>本地方法库(Native Method Libraries)<br>1.本地方法库时执行引擎调用的本地方法所在的库,这些库提供了Java程序可以调用的本地函数<br><br>执行引擎的工作流程大致如下:<br>1.类加载器将.class文件加载到JVM中<br>2.字节码验证其确保加载的字节码是有效的<br>3.解释器开始逐条执行字节码<br>4.在运行过程中,JIT编译器可能会识别出热点代码(执行频率高的代码),并将这些字节码编译成本地机器码以加速执行<br>5.如果程序调用了本地方法,JNI会负责调用相应的本地方法库中的函数<br><br>执行引擎的设计和实现因不同的JVM实现而异,但他们都遵循Java虚拟机规范,以确保Java程序在不同的JVM上能够一致地运行
Java为什么是半编译半解释型语言?<br>分成两个角度来看<br>1.触发JIT之前javac编译,java运行<br>2.触发JIT之后,运行期即时编译(C1、C2)+解释执行(模板解释器比字节码解释器高效很多)<br>
JVM的三种执行模式<br>解释模式<br>1.通过解释器(Bytecode Interpreter)解释执行<br>特点是:启动快(不需要编译),执行慢<br>可通过:-Xint参数执行为纯解释模式<br>2.编译模式<br>由JIT(Just In Time Compiler)编译为本地代码(C语言代码)主席那个<br>特点:启动慢(编译过程慢),执行快<br>可通过-Xcomp参数指定为纯编译模式<br>3.混合模式<br>混合使用解释器(Bytecode Compiler) + 热点代码编译(Just In Time Compiler)<br>起始阶段采用解释执行<br>热点代码检测(HotSpot),默认-XX:CompileThreshold=10000<br>多次被调用的方法(方法计数器:监测方法执行频率)<br>多次被调用的循环(循环计数器,监测循环执行频率)<br>对热点代码进行编译<br>默认采用这种模式,可通过-Xmixed指定<br><br>在JDK9及更高的版本中,JVM引入了AOT(Ahead-Of-Time)编译器。AOT编译允许在程序执行之前将Java字节码提前编译为机器码,从而避免或减少运行时的解释和即时编译。这种方式与传统的JIT不同,因为它在程序启动之前就完成了编译
AOT<br>提前编译是相对于即时编译的概念,提前编译能带来的最大好处是Java虚拟机加载这些已经预编译成二进制库之后就能够直接调用,而无须再等待即时编译器在运行时将其编译成二进制机器码。理论上,提前编译可以减少即时编译带来的预热时间,减少Java应用长期给人带来的“第一次运行慢"的不良体验,可以放心地进行很多全程序的分析行为,可以使用时间压力更大的优化措施。但是提前编译的坏处也很明显,它破坏了Java"—次编写,到处运行"的承诺,必须为每个不同的硬件、操作系统去编译对应的发行包;也显著降低了Java链接过程的动态性,必须要求加载的代码在编译期就是全部已知的,而不能在运行期才确定,否则就只能舍弃掉己经提前编译好的版本,退回到原来的即时编译执行状态。<br><br>AOT的优点<br><br>在程序运行前编译,可以避免在运行时的编译性能消耗和内存消耗<br>可以在程序运行初期就达到最高性能,程序启动速度快<br>运行产物只有机器码,打包体积小<br>AOT的缺点<br><br>由于是静态提前编译,不能根据硬件情况或程序运行情况择优选择机器指令序列,理论峰值性能不如JIT<br>没有动态能力<br>同一份产物不能跨平台运行<br><br>参考文章https://cloud.tencent.com/developer/article/2228910
Java被称为"半编译半解释型"语言,主要是因为它的编译和执行过程结合了编译和解释两种方式。这个特点使得Java既可以保持一定的执行效率,又具备良好的跨平台特性。<br>具体来说,Java的编译和执行过程如下:<br>1.源代码编译:Java程序员编写的Javaa源代码文件(.java文件)首先由Java编译器(javac)编译,生成字节码文件(.class文件).这个过程类似于其他编译型语言的编译步骤。<br>字节码是一种中间形式的代码,它并不是机器码,而是一种中间表示形式,主要是为Java虚拟机(JVM)准备的<br>2.字节码解释:编译后的字节码不是直接由操作系统执行的,而是由Java虚拟机(JVM)解释执行。JVM是一个虚拟的计算机,负责将字节码解释为特定平台的机器码,从而使得<br>Java程序得以运行。这种解释的方式使得Java程序可以在不同的操作系统上运行,只要改系统上有相应的JVM<br>3.即时编译(JIT):为了提高程序执行的效率,JVM还采用了即使编译技术(Just-In-Time Compiler, JIT)。当JVM执行字节码时,它会将某些频繁执行的字节码动态编译成机器码,<br>以提高执行效率。这种即时编译进一步缩短了程序的执行时间,接近于纯编译型语言的性能<br><br>总结来说,Java的"半编译半解释"指的是:<br>1.编译部分:Java源代码被编译成字节码,这一步类似于编译型语言的过程<br>2.解释部分:字节码由JVM解释执行,或在运行时通过JIT编译为机器码,这使得Java具备跨平台的能力,同事保证了较高的执行效率<br>这种设计使得Java既可以保证跨平台型,又能在执行效率上达到较好的平衡
两种解释器的底层实现,JVM中目前来说有两种解释器
字节码解释器<br>做的事情是:Java字节码->C++代码->硬编码,比如说一条_new指令,字节码解释器bytecodeInterpreter<br>C++代码中有很多跟new指令无关的才能到目标代码,好比你要执行一条字节码指令_new,字节码解释器需要执行很多操作,比如说要读取字节码文件对应索引的指令,才能到真正这条字节码需要做的事情,比较繁琐。一个字节码指令占一个字节,2^8-1=255,所以最多只能有255个
模板解释器<br>做的事情是Java字节码->硬编码<br>```c++<br>void TemplateTable::_new() {<br> transition(vtos, atos);<br><br> Label slow_case;<br> Label done;<br> Label initialize_header;<br> Label initialize_object; // including clearing the fields<br><br> Register RallocatedObject = Otos_i;<br> Register RinstanceKlass = O1;<br> Register Roffset = O3;<br> Register Rscratch = O4;<br><br> __ get_2_byte_integer_at_bcp(1, Rscratch, Roffset, InterpreterMacroAssembler::Unsigned);<br> __ get_cpool_and_tags(Rscratch, G3_scratch);<br> // make sure the class we're about to instantiate has been resolved<br> // This is done before loading InstanceKlass to be consistent with the order<br> // how Constant Pool is updated (see ConstantPool::klass_at_put)<br> __ add(G3_scratch, Array<u1>::base_offset_in_bytes(), G3_scratch);<br> __ ldub(G3_scratch, Roffset, G3_scratch);<br> __ cmp(G3_scratch, JVM_CONSTANT_Class);<br> __ br(Assembler::notEqual, false, Assembler::pn, slow_case);<br>……<br>```<br>内部维护了一个执行流数组RunStream[] arr = new RunStream[255];<br>arr[187] = {<br><br>0x55, // pushq %rbp<br>0x48,0x89,0xe5 // movq %rsp, %rbp<br>0xb8, 0x0b, 0x00,0x00,0x00 // movl $0xb, %eax<br>0x5d, // popq %rbp<br>0xc3 // retq<br>}<br>这种行为就叫硬编码编织,比C++执行效率要高,怎么理解呢?编织技术一种精细化的构造,使用的内存消耗小于原生的内存分配.<br>JIT底层实现(找到硬编码)<br>1.申请一块内存(可读可写可执行权限),线性地址用来存储代码<br>2.调用函数指针<br>3.硬编码写入<br><br>malloc申请的内存区域是可读可写的。模板解释器可以省区哪些没有意义的汇编代码
JVM三种运行模式<br>JIT为什么能提升性能呢?原因在于运行期的热点代码编译与缓存<br>JVM中有三种两种及时编译器,就诞生了三种运行模式<br>1.-Xint:纯字节码解释器模式<br>2.-Xcomp:纯模板解释器模式<br>3.-Xmixed 字节码解释器+模板解释器模式(默认)
-Xint
-Xcomp
-Xmixed
为什么没有用纯编译模式?<br>首次启动的时候要生成所有执行流,可能是担心生成执行流的时间比较长,用mixed的话,可以字节码解释器一边运行一边用热点代码编译
两种即时编译器(在虚拟机中习惯将Client Compiler称为C1,将Server Compiler称为C2)<br>jdk6以前是没有混合编译的,后来根据两种比那一起的使用场景组合起来使用进一步提升性能<br>1.C1编译器<br>-client模式启动,默认启动的是C1编译器。它的特点如下:<br>1.需要收集的数据比较少,即达到触发即时编译的条件比较宽松<br>2.自带的编译优化优化的点比较少<br>3.编译时和C2相比,没那么耗CPU,带来的结果是编译后生成的代码执行效率比C2低<br><br>2.C2编译器<br>-server模式启动。有哪些特点呢?<br>1.需要收集的数据较多<br>2.编译时很耗CPu<br>3.编译优化的点较多<br>4.编译生成的代码执行效率高<br><br>3.混合编译。<br>目前的-server模式启动,已经不是纯粹只使用C2.程序运行初期因为产生的数据较少,这时候执行C1编译,程序执行一段时间后,收集到足够的数据,执行C2编译器。早期触发C1,后面再触发C2,一个代码块有很多的执行流。热点代码存储再方法区元空间opcodecache热点代码缓存区域<br><br>C1和C2和GCC编译优化的-O0、O1、O2、O3类似
Client Compiler架构设计<br>对于Client Compiler来说,它是一个简单快速的三段式编译器,主要的关注点在于局部性的哟话,而放弃了许多耗时较长的全局优化手段。<br>1.在第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码标识(High-Level Intermediate Representation, HIR).HIR使用静态单分配(Static Single Assignment, SSA)的形式来代表代码值,这可以使得一些在HIR的构造过程之中和之后进行的优化动作更容易实现。在此之前编译器会在字节码上完成一部分基础优化,如方法内联、常量传播等优化将会在字节码被构造成HIR之前完成。<br>2.在第二个阶段,一个平台相关的后端从IHR中产生低级中间代码标识(Low-Level Intermediate Representation, LIR),而再次之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等,以便在HIR达到更高效的代码表示形式<br>3.最后阶段是在平台相关的后端使用线性扫描算法(LInear Scan Register Allocation)在LIR上做窥孔(Peephole)优化,然后产生机器代码
Server Compiler则是专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器,几乎能达到GNU C++编译器使用-O2参数的优化强度,它会执行所有经典的优化动作,如无用代码消除(Dead Code Elimination)、循环展开(Loop Unrolling)、循环表达式外提(Loop Expressin Hoisting)、消除公共子表达式(Common Subexpression Elimination)、常量传播(Constant Propagation)、基本块重排序(Basic Block Reordering)等,还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除(Range Check Elimination)、空值检查消除(Null Check Elimination,不过并非所有的空值检查消除都是依赖编译器优化的,有一些是在代码运行过程中自动优化了)等。另外,还可能根据解释器或Client Compiler提供的性能监控信息,进行一些不稳定的基金优化,如守护内联(Guarded Inlining)、分支频率预测(Branch Frequency Prediction)等。<br><br>Server Compiler的寄存器分配是一个全局着色分配器,它可以充分利用某些处理器架构(如RISC)上的大寄存器集合。以即时编译的标准来看,Server Compiler无疑是比较缓慢的。但它的编译速度依然远远超过传统的静态优化编译器,而且它相对于Client Compiler编译输出的代码质量有所提高,可以减少本地代码的执行时间,从而抵消了额外的编译时间开销,所以也有很多非服务端的应用选择使用Server模式的虚拟机运行<br><br>JIT编译过程本来就是一个虚拟机中最体现技术水平也是最复杂的部分,不可能以较短的篇幅就介绍得很详细,另外,这个过程对Java开发来说是透明的,程序员平时无法感知它的存在。
JIT(Just-In-Time,即时编译)<br>针对的是热点代码(触发JIT的条件)<br>Client模式:32bit才有<br>Server模式:64bit<br>触发条件后,谁来编译,编译线程<br>C1:Client模式下<br>C2: Server模式下<br>JDK6之后,混合在一起,<br>热点代码((统计的并不是被调用的绝对次数,而是一个相对的执行频率,一段时间内方法被调用的次数))其中包括<br>1.方法<br>2.代码块 循环<br>Client模式下:1500次执行触发<br>Server模式下:10000次执行JIT及时编译<br>JIT的底层实现,达到条件之后会以异步队列的方式Queue封装成VM:Operation入队,然后叫i给VM:Thread:loop<br><br>但是热点代码会有一个热度衰减的概念,<br>比如执行了7000次,在执行3001次之后触发JIT,但是过了1min之后,可能需要执行6501次才可以,相当于1min前的7000次衰减为了3500次<br>热度衰减的原因:确保是热点代码,不然触发JIT的代码会越来越多
方法调用计数器触发即时编译
查看及分析即时编译结果(需要开启VM 参数-XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+PrintInlining)<br>```java<br>public class JITTest {<br><br><br> public static final int NUM = 15000;<br><br> public static int doubleValue(int i) {<br> // 这个空循环用于后面演示JIT代码优化过程<br> for (int i1 = 0; i1 < 100000; i1++);<br> return i * 2;<br> }<br><br> public static long calcSum() {<br> long sum = 0;<br> for (int i = 0; i < 100000; i++) {<br> sum += doubleValue(i);<br> }<br><br> return sum;<br> }<br><br> public static void main(String[] args) {<br> for (int i = 0; i < NUM; i++) {<br> calcSum();<br> }<br> }<br>}<br>```<br>带%的输出说明是由回边计数器触发的即时编译,可以看到calcSum()和doubleValue()方法都已经被即时编译,并且还看到了doubleValue方法已经被内联编译到calcSum()方法中
热点代码缓存区<br>热点代码缓存时保存在方法区的,这块也是调优需要调的地方<br>server编译器模式下代码缓存大小起始于2496KB<br>client编译器模式下代码缓存大小起始于160KB
热机且冷机故障(热机在崩溃的边缘)<br>新加的机器,流量切过去之后,就挂了。热机运行了很长时间。冷机才刚运行不久。冷机字节码解释器模式,CPU升高就挂了<br>原因:Java刚启动时,一段时间触发JIT之后,性能才会达到最高。<br>怎么解决呢?<br>1.缓缓的切流量,慢慢测<br>2.加更多的机器。<br>JIT触发,会将多个执行流合并
逃逸分析<br>逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术,它与类继承关系分析一样,并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。<br>逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。甚至还有可能被外部线程访问到,比如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。<br>如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化,如下所示:<br>1.栈上分配(Stack Allocation)<br>Java虚拟机中,在Java堆上分配创建对象的内存空间几乎时Java程序员都清除的常识了,Java堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问堆中存储的对象数据。虚拟机的垃圾收集系统可以回收堆中不再使用的对象,但回收动作无论是筛选可回收对象,还是回收和整理内存都需要耗费时间。如果一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存将会是一个很不错的注意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集系统的压力将会小很多。<br>2.同步消除(Syncrhonization Elimination):线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那这个变量的读写肯定就不会有竞争,堆这个变量实施的同步措施也就可以消除掉<br>3.标量替换(Scalar Replacement):标量(Scalar)是指一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型以及类型reference类型等)都不能再进一步分解,它们就可以称为标量。相对的,如果一个数据可以继续分解,那它就称做聚合量(Aggregate),Java中的对象就是最典型的聚合量。如果把一个Java对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,有很大的概率会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。<br><br>在早期阶段,这项技术还不够成熟,原因主要是不能保证逃逸分析的性能收益必定高于它的消耗。如果要完全准确地判断一个对象是否会逃逸,需要进行数据流敏感的一系列复杂分析,从而确定程序各个分支执行时对此对象的影响。这是一个相对高耗时的过程,如果分析完后发现没有几个不逃逸的对象,那这些运行期好用的时间就白白浪费了。
开始栈上分配,创建100w个对象,HSDB查看对象的数量,只有11w个对象
关闭栈上分配
13.让JVM有自己的数据类型<br>
Java<br>boolean 1B<br>byte 1B<br>char 2B<br>short 2B<br>int 4B<br>long 8B<br>float 4B<br>double 8B<br>oop 4B | 8B(取决于操作系统的位数、指针压缩是否开启)<br><br>C++<br>char 1B<br>short 2B<br>int 4B<br>float 4B<br>long 8B<br>double 8B<br>指针4B、8B(取决于操作系统的位数)
数据在内存中是如何存储的?<br>操作数栈/局部变量表中的一个格子称为一个插槽(Slot).虚拟机栈会对数据类型进行封装,在HotSpot源码中会封装成StackValue结构,其中有一个type属性会表示类似的0x44332211是内存地址还是一个数值,用来帮助GC Root分析<br><br>C++在编译的时候,汇编代码已经区分好了数据占多少个字节<br>
内存的存储模式<br>1.直接用int(32bit) long (64bit) 浪费内存<br>2.byte数组 C++全部用这种方式实现的,在汇编层面就确定了,这个数据类型<br>3.Object int、long(Java特有的)<br>4.混合用<br><br>数据<br>1.小于4B的全部用int存储<br>2.double用两个int存储<br>3.引用类型全部用object存储<br>
对于8字节的数据类型,在Java常量池中占两个Slot,double的存储时分成了两个高低位,对于常量池解析时,字节码是拆开了,但是我们在自己编写时可以进行合并起来。不一定非要和HotSpot源码一样,存的时候是合起来,取的时候也是合起来取。<br><br>如代码所示,我们定义了一个值为10的double类型变量a,ldc2_w字节码指令将常量池推送到了操作数栈上,我们可以跟着去看下它的常量池项存储的内容。<br>
常量池项内容如图所示,我们可以看到它是把double类型分割成了高低位
14.让JVM能进行运算
double常量池解析<br>解析常量池时已经合并,真正在用的时候,就是在处理合与分<br>字节码指令解析、编译系统、JIT都是按照4B来设计的,这个坑迟早要填的<br>
虚拟机栈和OS的栈之间的关系(寄生关系)<br>虚拟机栈本身是OS栈,每个Slot其实是8B,但是JVM把它当4B来用。<br>寄生对象是Call Stub,它会创建OS栈,先申请一段OS栈,然后再延申一段JVM的变量用来存储。执行原生栈时,才会创建OS栈,JVM执行main方法是不会生成OS栈的
```java<br>// 代码1<br>int i = 1;<br>System.out.println(i++);<br><br>// 代码2<br>float v = 1;<br>System.out.println(v++);<br>```<br>为什么代码1的执行效率高,因为代码1是专属指令<br>iinc 0 by 1 局部变量表中index=0的位置加1,写回去<br>iinc就是加1,为什么还要写出来?原因在于iinc不只是用于++操作还可以用来--操作、+=操作,这也是JVM设计者的精髓巧妙之处(还节省了一个字节码指令的位置),如图所示。操作步骤如下:<br>1.第一个操作数: 拿到slot的index<br>2.第二个操作数: 增加或减少的步长<br>3.完成运算<br>4.写回局部变量表<br><br>为什么说JVM的字节码指令位置是很紧张的呢?因为一个字节码指令设计的时候只给了一个字节,一个字节的话,无符号的话,最大是255,言外之意,JVM的字节码指令最多只给255个,现在已经用了202个
byte++和i++哪个效率更高?<br>int++只对应了一句字节码指令,而byte++对应了五句字节码指令。性能会有5倍的差异。
iinc指令拓宽<br>Intel CPU是怎么解决指令集不够用的问题呢?最开始其实也只有一个字节,后面扩展成二级指令,现在已经到三级指令了。所以其实你的知识面越底层,真正的理解底层,你就能预测出未来可能到来的变化:比如这里说的,JVM当前的以及指令不够用了,它也要引入二级指令。这就是阿里P8的硬要求之一:技术前瞻性。<br><br>iinc指令<br>不管是i++还是i--操作,编译后对应的字节码指令都是iinc指令。如图所示,iinc字节码结构如图所示。<br>1.IINC:对应的是字节码指令<br>2.Slot Index对应的是这个指令作用的局部变量在局部变量表中的索引<br>3.step 一次加多少。固定值为1,如果这个值不存在特殊考虑,这个字节可以节省下来。这个要看JDK后面会不会有这个计划<br><br>超级加倍<br>不知道大家有没有发现一个问题,iinc指令的第一个参数,即代表slot index这个参数,只有一个字节,这就是i++、i--存在的约束。这个约束就是如果局部变量的索引超过128,就只能走byte++那种复杂的逻辑。那么JVM是怎么做的呢?JVM做了指令拓宽,增加了一个字节位
15.让JVM支持Lambda表达式
代码所示
诞生的背景<br>研究一个伟大的技术,不了解它的过去不足以更好地理解它的现在甚至它的未来。我们先来看看Lambda表达式时如何一步步在JVM中生长出来的。<br>在JDK8之前,我们想使用某个接口实现类,要么提前写好实现类,要么使用匿名内部类的方式。匿名内部类代码如下<br>```java<br>interface GreetingService {<br> void greet(String message);<br>}<br><br>public class Main {<br> public static void main(String[] args) {<br> // 使用匿名内部类实现GreetingService接口<br> GreetingService greetingService = new GreetingService() {<br> @Override<br> public void greet(String message) {<br> System.out.println("Hello, " + message);<br> }<br> };<br><br> // 使用匿名内部类创建的对象<br> greetingService.greet("World");<br> }<br>}<br>```<br>提前写好实现类的问题一个是项目定义的类会特别多,其次是有些接口中需要实现的方法很少,定义一个实现类显得有点笨重。后来JDK支持了匿名内部类,对于需要实现方法比较少的接口,就直接采用这种方式实现了,一切都显得如何和谐自然。<br>代码重复如此严重,免不了被其他语言嘲讽,于是大佬们就受不了了,免不了再次升级,一场革命酝酿着。于是Lambda表达式诞生了,顺便带来了函数式接口注解@FunctionalInterface<br><br>Lambda表达式实现依托三个东西:<br>1.匿名内部类(VM Anonymous Class)<br>2.invokedynamic<br>3.MethodHandle<br>可以这样说,在Lambda表达式诞生之前,它依托的技术JVM中就已经全部支持了,那为什么Lambda表达式到JDK8才诞生呢?因为需求都是慢慢生产出来的
添加下面的JVM参数,可以dump出Lambda表达式生成的对象<br>-Djdk.internal.lambda.dumpProxyClasses
字节码层面<br>上面代码写出的lambda表达式,对应的字节码文件会多出一些内容<br>
1.常量池中会有一项:JVM_COnstant_InvokeDynamic_info
2.类属性里面会有一项:BootstrapMethods
3.常量池中还会出现两个JVM_CONSTANT_MethodHandle,一个JVM_CONSTANT_MethodType
JVM_CONSTANT_MethodHandle_info(LambdaMetafactory)
JVM_CONSTANT_MethodHandle_info(lambda$main$0)
JVM_CONSTANT_MethodType_info
4.还会多出一个方法。这个方法是编译器自动生成的。所以可以这样说,Lambda表达式的实现,是编译系统与运行系统互相配合实现的
5.Lambda表达式的调用指令是invokedynamic
如何实现调用
1.通过indy后面后面的操作数,拿到常量池中的信息:JVM_CONSTANT_InvokeDynamic.大家有没有注意到,我们Java代码中的run方法返回类型是void,编译之后却是CustomLambda。言外之意,这一步执行完会创建CustomerLambda的实现对象。
2.从常量池项JVM_CONSTANT_InvokeDynamic中拿到BootstrapMethods的索引。即我们目前调用的是第几个BootstrapMethod
3.BootstrapMethod结构中的Bootstrap方法,对应的常量池项是MethodHandle.JVM就是通过执行LambdaMetafactory.metafactory创建出CallSite,进而创建出Lambda表达式对应的对象的。BootstrapMethod结构中的其他信息,都是一些辅助信息,是调用mefafactory方法需要传的参数<br><java/lang/invoke/LambdaMetafactory.metafactory : (Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;><br>
4.JVM也是通过执行LambdaMetafactory.metafactory完成Lambda表达式对应的函数式接口与具体实现中的代码的关联。背后的实现原理就是通过字节码,我们可以使用-Djdk.internal.lambda.dumpProxyClasses可以将生成的类保存到文件中。看下生成的文件内容
总结来说就是JVM通过BootstrapMethod找到LambdaMetafactory.metafactory并执行,完成Lambda表达式对应的函数式接口与具体实现中的代码的关联,默认的,在内存中会生成一个新的类,并返回这个类的实例,所以,可以这样调用run方法<br>```java<br>obj.run(1);<br>```
自实现的JVM如何支持Lambda表达式<br>我们想要的是如何能够生成MethodHandles.Lookup,本能的会想到,MethodHandles.lookup(TestLambda.class),但是没有这样的API实现,我们需要用MethodHandle + 反射来实现<br><br>Lambda底层是由MethodHandles生成,然后生成动态调用点,在内存中生成一个TestLambda$$Lambda$类似的实现类文件<br><br>实现Lambda表达式的一个细节<br>最开始的<br>Method fun = obj.getClass().getMethod(methodName, paramsClass);<br>改过之后:<br>Class<?> clazz = Class.forName(className.replace('/','.'));<br>反射生成的TestLambda$$Lambda$1
16.让JVM支持异常处理
JVM为什么会捕获到异常?除零异常的底层实现<br>1.JVM如何知道发生了除零异常,HotSpot时怎么知道的<br>2.OS又是如何知晓的? Linux信号处理机制<br>3.CPU又是如何知晓的?<br>CPU如何记录异常,如何通知给操作系统<br>寄存器 eflags 有一个状态为会标记出来<br>触发异常机制 SIGFPE给我回调函数 触发软中断<br><br>游戏的防护、辅助工具,会正常触发异常,然后在异常处理里面做修复,在异常处理里面隐藏真正的逻辑
四种异常处理的方式<br>1.int a = 1/ 0; 直接不管<br>2.代码throw new RuntimeException ATHROW指令<br>3.try catch<br>4.方法签名抛出throws<br><br>当使用try catch捕获异常时,Method code会有一些变化,异常表会存储,多一个goto指令,存储异常信息
手写三色标记算法
三色标记算法理论+STW原理剖析
GC的发展<br>1.内存结构的发展<br>一开始是一块整堆,<br>2.GC算法的发展,由于Java是自动垃圾回收,所以回收算法效率会很重要<br>标记-清除:容易发生碎片化<br>标记-整理:它虽然解决了碎片化问题,但是它是一个CPU密集型,耗时长短和堆大小有关。<br>标记-复制:所以后面内存结构就划分为了两半,采用复制的方式来转移存活对象,但是发现内存空间浪费太严重,于是有人根据对象的存活周期和统计学理论划分除了新生代与老年代。也就有了新生代8:1:1的划分<br><br>Region模型的由来<br>G1如果想要做到可预测停顿的话,那么内存空间就不能太大,如果不重新设计模型,当扫描大块的内存空间时将会耗费很长时间,所以它必须重新规划堆区,这也就有了Region模型,每个Region固定大小2M<br><br>再想提升效率,那么将就得多开几个线程来解决<br>比如串行回收调整成并行回收<br>再到后面再调整成并发回收<br><br><br>
GC的过程可以划分为三个:标记、清除、复制<br><br>三色标记算法<br>黑色:新产生的对象,已经扫描完引用关系的对象<br>灰色:当前对象已扫描完,还没扫描引用关系<br>白色:所有没有被扫描到的对象或待清理对象<br><br>标记<br>1.首先要扫描出根据对象GC Roots(这个阶段需要STW,因为要确保不会产生新对象,不会出现引用关系的变动),大概需要花费50-100ms<br>那么哪些对象可以作为GC Roots呢?<br>栈中指向堆中的对象、静态属性、字符串常量池这些<br>2.接下来要扫描根对象引用的其他对象,这个属于并发标记,可以和用户线程一起执行。这个阶段的耗时跟堆的大小成正比。<br>a.会产生新对象(多标)<br>对于新产生的对象三色标记算法中是将其视为黑色,留到下次GC时清理,不会在程序中引起报错,就是占用堆空间而已<br>b.黑色对象使用完之后,已经不在被其他对象使用,也会留到下次GC时清理(少标)<br>c.会发生引用关系的变动(漏标问题)<br>漏标分为两种情况:<br>1.灰色对象断开了白色的引用。<br>记录引用关系变动之前的关系图 原始快照(写前屏障 G1)<br>2.黑色对象建立了对白色的引用<br>记录引用关系变动之后的关系图 增量更新(写后屏障 CMS)<br><br>还有一个问题就是原始快照、增量更新产生的这些引用关系变动的记录会放到哪里呢?<br>CMS:会放到一个脏页当中<br>G1会将oop放入到一个队列中<br><br>最终标记:对并发标记中引用关系发生变动的记录做进一步处理,这个阶段需要STW<br><br>
STW底层实现原理<br>1.JVM得知道什么时候需要STW<br>启用安全点的方式<br>SafepointSyncrhonize::begin()<br><br>那么如何激活安全点的呢?<br>-poling_page属性改为NONE(不可读不可写)<br>2.所有的线程是如何感知到启用了安全点<br>a.安全点插入的位置,取决于让程序长时间运行的可能代码,一般常见于<br>大的循环里面或者方法结束的位置|<br>在一个大的循环里面需要wide_vectors才会启用,对于像int类型的循环不会,long类型才会。还有一个是方法结束的时候<br><br>插入的内容就是<br>test %eax os::_polling_page<br>这段汇编代码会有两个效果<br>对于正常执行情况下,可读可写的时候,不会产生任何效果<br>对于GC情况下,不可读不可写的时候,就会触发段异常 SIGSEGV<br>安全点的实现就是基于段异常来实现的,利用的是Linux的信号处理机制,我们可以用kill -l 查看信号类型<br><br>JVM会注册这个SIGSEGV这个信号,设置一段回调函数<br>register(SIGSEGV, () {<br> 执行STW逻辑<br>})<br>HotSpot通过JVM_handle_linux_signal这个函数进行注册的,来拦截默认的Linux信号处理。JVM是不希望Linux系统来处理这个逻辑,所以进行了拦截<br><br><br>但是呢?JVM操作空指针也会出现段异常,那么JVM是怎么识别是_poling_page触发的还是空指针触发的呢?<br><br>if (sig == SIGSEGV && os::is_poll_address((address)info->si_addr)) {<br><br>poling_page是什么呢?<br>// Allocate a single page and mark it as readable for safepoint polling<br> address polling_page = (address) ::mmap(NULL, Linux::page_size(), PROT_READ, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);<br> guarantee( polling_page != MAP_FAILED, "os::init_2: failed to allocate polling page" );<br><br> os::set_polling_page( polling_page );<br>3.JVM是如何找到所有的线程的?Linux是没有API能够返回当前进程的所有线程呢?其实是JVM自己保存的<br>Threads<br>Thread又分JavaThread、WatcherThread<br>4.所有的线程是如何暂停的?在哪里暂停的<br>遍历线程单链表逐个Park<br><br>HotSPot STW源码<br>信号处理:JVM_handle_linux_signal<br>申请poling_page:os:init_2<br>更改poling_page<br>make_poling_page_unreadable<br>make_poling_page_readable<br><br>test指令就是都内存,在手写时,如果写C++代码,翻译成汇编需要很多很多的指令,所以可以直接读/写 polling_page即可(汇编也是对应的test指令)
安全点的插入位置
Linux中的信号类型
HotSpot注册Linux信号函数的地方
HotSpot识别空指针和STW的地方
poling_page就是一块内存地址
HotSpot在init_2方法中对poling-page进行赋值的
HotSpot是如何存储所有线程的
HotSpot会在线程创建的时候放入到线程链表中
更改polling_page
OopMap的实现原理,OopMap里面记录了对象之间的引用关系帮助GC在STW时期快速找到GC Roots
OopMap的作用<br>在Java虚拟机中,OopMap(ObjectOops Map)是一个重要的数据结构,它用于在垃圾回收(特别是Stop-The-World垃圾回收)期间,帮助虚拟机确定堆栈上对象引用的位置。以下是OopMap的主要作用:<br>1.确定GC Roots:在垃圾回收过程中,JVM需要找出所有的根集合,即直接或间接引用堆中对象的地方。这包括全局变量、活跃线程的栈帧中的局部变量和操作数栈上的引用、静态变量、常量<br>2.减少扫描范围:如果没有OOpMap,JVM在垃圾回收时需要扫描整个栈来查找所有的引用,这会非常低效。OopMap允许JVM只关注那些可能可能包含对象引用的栈帧部分,从而减少扫描的工作量<br>3.支持精确的垃圾回收:有了OopMap,JVM可以执行精确的垃圾回收,即准确知道哪些对象是存活的,哪些是垃圾。这有助于实现CMS、G1等现代垃圾回收算法<br>4.优化停顿时间:由于OopMap的存在,JVM可以在Stop-The-World时间发生时快速地完成根集合的扫描,从而减少应用程序的停顿时间<br>5.处理栈上变化:当JVM执行代码时,它会在特定的安全点(safepoints)更新OopMap.这些安全点时代码执行的特定位置,在这个位置上,JVM可以安全地暂停线程并更新OopMap,以反映栈和寄存器中的最新状态<br>6.协助调试和诊断:在调试和诊断虚拟机问题时,OopMap提供的信息可以帮助开发者理解对象的引用关系,以及它们在特定时间点的状态
GC标记的核心<br>区分出活跃对象和非活跃对象,GC Roots就是一批活跃的对象。以栈中的对象为例,栈中的对象是直接存放数值还是内存地址?<br>我们可以设想出两种方案:<br>1.全部封装成一个对象,里面标识这个位置是什么类型<br>2.直接放数据(JVM的做法,不是很理解)<br><br>在面对栈中的0x7fddd这样的一个字面量时,JVM如何知道它的类型是一个引用地址还是数值呢?于是GC又分为两类:<br>保守式GC 不能识别指针与非指针的GC<br>准确式GC 精准识别<br>HotSpot版本的虚拟机实现是准确式GC,它是通过借助第三方数据结构——栈图来实现的。在手写JVM的Java版本中是每个数据都会封装成一个对象(StackValue),里面有个标识,标识这个对象是什么类型。伪代码如下<br>```java<br>StackValue v = new StackValue(T_INT, 1);<br>// 遍历所有线程<br>for (Thread t : threads) {<br>// 遍历线程的栈帧<br> for (StackValuie v : t.frame()) {<br>// 如果是引用类型,并且被GC Roots引用,则加入到OopMap当中<br> if (T_OOP) {<br> OopMap.insert(v);<br> }<br> }<br>}<br>```
栈图是什么?<br>对于基本类型和引用类型,将它们的值保存在局部变量数组和操作数栈中的助记符是不同的。基本类型的助记符是istore_1,而引用类型的助记符是astore_2.<br>HotSpotVM利用字节码的类型信息,来创建发生GC时栈帧的栈图。顾名思义,栈图就是表示将引用类型保存在局部变量数组和操作数栈上什么位置的地图。实际的栈图是以00100这样的比特序列表示的。比特序列中值为1的比特,表示它所对应的局部变量(或操作数栈)中保存的引用类型的值<br><br>抽象解释器<br>栈图是由抽象解释器创建出来的。简单地说,抽象解释器就是只记录类型信息的解释器。抽象解释器只记录保存在局部变量和操作数栈中的值的类型,并不关心实际保存的值。如代码示例,来比较一下抽象解释器和普通解释器的行为<br>```java<br>BasicBlock#0<br>pc( 0): locals = 'r ', stack = '' // iconst_1<br>pc( 1): locals = 'r ', stack = 'v' // istore_1<br>pc( 2): locals = 'rv ', stack = '' // new #2<br>pc( 5): locals = 'rv ', stack = 'r' // dup<br>pc( 6): locals = 'rv ', stack = 'rr' // invokespecial #1<br>pc( 9): locals = 'rvr', stack = 'r' // astore_2<br>pc(10): locals = 'rvr', stack= '' // return<br>```<br>抽象解释器的执行流程很好写,如上所示。locals是局部变量数组,stack是操作数栈。请将局部变量数组或操作数栈中的r(reference)当作引用类型,把v(value)当作基本类型。<br>抽象解释器会记录某个命令集被执行前的局部变量数组和操作数栈的类型信息。例如在pc0处,他会记录iconst_1被执行前的类型信息。因此,局部变量数组(locals)中只有表示参数args的类型r将被记录下来。接下来,在pc1处执行完iconst_1后,操作数栈(stack)中表示1的类型v将被记录下来。<br>抽象解释器就是像这样毫不关心实际值,只记录类型信息的。接着,HotSpotVM就会根据抽象解释器记录下的对应了一次字节码执行的类型信息创建栈图<br><br>下一步则是通过栈图找到GC Roots,然后让如OopMap中<br><br>没执行一条字节码指令都需要操作下栈图
代码示例<br>字节码含义<br>iconst_'i'<br>将相当于'i'部分的int类型的常量添加到操作数栈中<br>istore_'n'<br>将操作数栈头部的int类型的值保存到局部变量数组的第'n'个元素中<br>new<br>创建一个新的对象并将其添加到操作数栈<br>dup<br>复制操作舒展头部的值并将复制出的值添加到操作数栈中(在new 对象时,是会给this指针赋值的)<br>invokesepcial<br>调用实例的初始化方法等特殊方法<br>astore_'n'<br>将操作数栈头部的引用类型的值保存到局部变量数组的第'n'个元素中<br>return<br>从方法中返回void
字节码的执行流程<br>最终,局部变量1中存放的是1,局部变量2中存放的是Object类的实例的地址。局部变量1是上面代码中的变量primitiveType,局部变量2是变量referenceType,像上面这样一边读取字节码,一边逐个执行命令集的解释器称为"字节码解释器"
BasicBlock的结构,生成的OopMap是以BasicBlock为单位的,每个执行块都有一个OopMap
当一个对象及其引用关系被遍历完之后,会OopMap中放入到black table(黑色对象集合当中)<br>而当一个对象仅仅本身被扫描完,则会从OopMap中放入到gray table中
漏标是如何记录的?<br>G1:是利用写前屏障+原始快照的方式将oop放入到一个队列里面<br>CMS:利用写后屏障+增量更新的方式写入到脏页当中<br><br>这个引用关系的变动是被记录在线程的一个属性队列里面的
0 条评论
下一页