概述
Java不是最强大的语言,但是JVM是最强大的虚拟机。
常见的JVM:Sun公司Classic和HotSpot、BEA公司的JRock、IBM的J9、Graal虚拟机是未来发展的方向。
Java虚拟机的启动是通过引导类加载器(bootstrap class loader)创建一个初始类(initial class)来完成的,这个类是由虚拟机的具体实现指定的。
HotSpot虚拟机主要使用C++开源,JNI接口部分使用C实现,使用了JIT(just in time)编译器,可以提高性能。
字节码
Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class”文件这种特定的二进制文件格式所关联。
Java语言是半编译半解释性语言,同时拥有解释器和JIT对热点代码进行编译。
前端编译器的主要任务就是负责将符合Java语法规范的Java代码转换为符合JVM规范的字节码文件。前端编译器主要是Javac编译器,还有ECJ编译器,与Javac的全量编译不同,ECJ是增量编译。
javac编译器生成字节码的步骤是词法解析、语法解析、语义解析以及生成字节码。
会生成Class的对象:Class、interface接口、数组、enum枚举、annotation注解、基本数据类型和void。
数组只要元素类型和维度一样,就是同一个Class。
Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的操作码(opcode)以及跟随其后的零至多个代表此操作所需参数的操作数(operand)所构成。
String声明的字面量数据都放在字符串常量池中:JDK6中字符串常量池存放在方法区,即永久代中;JDK7及以后字符串常量池存放在堆空间。String.intern()是一个Native方法,它的作用是:如果字符常量池中已经包含一个等于此String对象的字符串,则返回常量池中字符串的引用,否则将新的字符串放入常量池,并返回新字符串的引用。因此在不同版本的JDK中会产生差异。
Father f = new Son()
会先执行父类的构造方法,再执行子类的构造方法。
字节码指令:加载与存储指令、算术指令、类型转换指今、对象的创建与访问指令、方法调用与返回指令、操作数栈管理指令、控制转移指令、异常处理指令、同步控制指令。
Java虚拟机有一个只在内部使用的基本数据类型:returnAddress。
堆比栈要大,但是栈比堆的运算速度快,将复杂数据类型放在堆中的目的是为了不影响栈的效率,栈管运行,堆管存储。
Java中的参数传递是传值,而且没有指针的概念。
Class文件结构
Class文件结构:魔数、Class文件版本、常量池计数器、访问标识(标记)、类索引,父类索引,接口索引集合、字段表集合、方法表集合、属性表集合。
符号引用在字节码中,与虚拟机实现的内存布局无关。直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的。
名称 | 作用 |
---|---|
魔数 | Class文件的标志,确定这个文件是否为一个能被虚拟机接受的有效合法的class文件 |
class版本号 | 分为主版本和副版本,与编译器版本对应 |
常量池计数器 | 常量池容量计数值,从1开始,后面跟着常量的信息 |
字段表集合 | 字段包括类级变量以及实例级变量 |
包装类对象的缓存问题
包装类 | 缓存对象 |
---|---|
Byte、Short、Integer、Long、 | -128~127 |
Character | 0~127 |
Boolean | true和false |
Float、Double | 没有 |
类的加载
类的加载过程
类的加载过程:装载、链接(验证、准备、解析)、初始化、使用、卸载。
基本数据类型由虚拟机预先定义,引用数据类型则需要进行类的加载。
装载阶段:查找并加载类的二进制数据,在内存中构建出Java类的原型——类模板对象。
栈中拥有指向堆的Class对象,Class对象中拥有指向方法区(元空间)的类的数据结构。
数组类本身并不是由类加载器负责创建,而是由JVM在运行时根据需要而直接创建的,但数组的元素类型仍然需要依靠类加载器去创建。
验证阶段:格式检查、语义检查、字节码验证和符号引用验证。验证阶段会与装载阶段同时执行。
准备阶段会为类的静态变量分配内存,并将其初始化为默认值,如果有final修饰则会进行显式赋值。
解析阶段将类、接口字段和方法的符号引用转为直接引用。
在HotSpot虚拟机中,加载、验证、准备和初始化会按照顺序有条不紊地执行,但链接阶段中的解析操作往往会伴随着JVM在执行完初始化之后再执行。
初始化阶段为类的静态变量赋予正确的初始值。在初始化阶段才会真正开始执行类中定义的Java代码。初始化阶段的重要工作是执行类的初始化方法:<clinit>()
方法,只有在给类的中的static的变量显式赋值且不被final修饰或在静态代码块中赋值才会使用该方法。
Static和final搭配使用,在赋值为Interger.valueOf(11)
这种有方法或构造器调用的情况下,仍然是在初始化阶段加载。还有例如static int a = 9;static final int b=a
,b也是在初始化阶段才加载。
函数clinit
是带锁线程安全的,因此可能会出现死锁问题。例如在A类尝试加载B类,在B类中尝试加载A类。
Java进行初始化的情况:创建类的实例、调用静态方法、使用类和接口的静态字段(final修饰存在特殊情况)、使用Java.lang.reflect包中的方法反射类的方法、初始化子类前若父类未初始化则对父类进行初始化、如果一个接口定义了default方法,那么直接实现或者间接实现该接口的类的初始化,该接口要在其之前被初始化。还有main方法的主类。
为什么是对静态方法和值进行初始化,因为类被加载了,那么就有可能访问其中的静态字段,因此需要初始化。
被动使用不会引起类的初始化的情况:当访问一个静态字段时,只有真正声明这个字段的类才会被初始化。例如当通过子类引用父类的静态变量,不会导致子类初始化。通过数组定义类引用,不会触发此类的初始化。引用常量不会触发此类或接口的初始化。调用classLoader类oadc1ass()方法加载一个类,并不是对类的主动使用,不会导致类的初始化。
类的加载器
ClassLoader在整个类的加载阶段值参与装载阶段。
每一个类加载器,都拥有一个独立的类名称空间:比较两个类是否相等,只有在这两个类是由同一个加载器加载的前提下才有意义。
Java自带的类加载器:引导类加载器、拓展类加载器和系统类加载器。
引导类加载器(Bootstrap ClassLoader)用于加载Java的核心库。
自定义类的话不重写loadClass方法,一般是重写findClass方法。
自定义类加载器的好处是:隔离加载类、修改类加载的方法、拓展加载源和防止源码泄漏。
核心类库不能由非启动类加载器加载,因为在ClassLoader.defineClass中会由preDefineClass接口提供保护。
双亲委派模式的弊端是上层无法使用下层加载的类。如果想要上层使用下层加载的类,可以使用线程上下文加载器实现。
破坏双亲委派模式的最好例子是Tomcat的类加载模式,在Java自带的类加载器加载完成后,会从底部类加载器开始加载,加载不了的再由更高层的类加载器加载。
热替换的思路是使用新的类加载器加载,然后将旧的类加载器清除。
运行时内存
线程独占:栈、本地方法栈、程序计数器;线程共享:堆和方法区。
本地方法就是Java调用非Java代码的接口。
栈
栈主管Java程序的运行,它保存方法的局部变量(8种基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回。
栈不存在垃圾回收机制,但存在内存溢出问题。
栈可以设置为动态分配内存,因此除了常见StackOverflow错误外,还可能出现OOM错误。
JDK5之前,默认栈大小为256K,JDK5之后是1024K。
栈和堆的差异:GC和OOM;运行效率、内存大小和数据结构、栈管运行;堆管存储。
栈帧:局部变量表、操作数栈、动态链接、方法返回地址和附加信息。
局部变量表是一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型(8种)、对象引用(reference),以及returnAddress类型。
在局部变量表中,long和double会占据两个长度,并且槽位是可以复用的,如果一个局部变量过了其作用域,那么在其作用域之后声明的新的局部变量就很有可能会复用过期局部变量的槽位。
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
操作数栈主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
栈顶缓存技术将栈顶元素全部缓存在物理CPU的寄存器中。
局部变量表和操作数栈的长度在编译完成后就已经计算得出。
堆
一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。
在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
TLAB:Eden区为每个线程分配了一个私有缓存区域。
几乎所有的Java对象都是在Eden区被new出来的,大对象会直接分配到老年代。
堆的最大值默认是物理内存的四分之一,最小是物理内存的十六分之一。
垃圾回收:频繁在新生代收集、很少在养老区收集、几乎不在永久区收集。
特殊情况:如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。
空间分配担保机制:在MinorGC前比较老年代的最大可用连续空间是否大于新生代所有对象的总空间,如果小于,可以选择比较是否小于新生代MinorGC后平均的剩余空间,如果仍小于,则会进行FullGC。
MinorGC:针对新生代的;MajorGC:针对老年代;FullGC:针对整个Java堆和方法区。但是很多时候MajorGC和FullGC混着说。
方法区
元空间溢出的场景:加载大量的第三方的jar包;Tomcat部署的工程过多(30-50个);大量动态的生成反射类。
JDK7及以前将方法区称为永久代,JDK8及以后将其称为元空间,使用内存而不是虚拟机的空间。
与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutofMemoryError:MetasRace。
从永久代改为元空间的原因:难以确定永久代的大小、难以对永久代调优。
在JDK6及以前,静态变量和字符串常量池都放在方法区,之后改为放在堆中。
方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型。
对象内存布局
创建对象的步骤:判断对象对应的类是否加载、链接、初始化;为对象分配内存:指针碰撞或空闲列表;处理并发安全问题;初始化分配到的空间;设置对象的对象头;执行init方法进行初始化。
如果内存规整,那么使用指针碰撞,指针未分配内存中有序获取所需内存大小。否则使用空闲列表分配。
在Java程序的视角看来,直到执行init方法进行初始化,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。
父类的私有属性也会放在实例中,只是不能使用而已。
数据存放的规则是相同宽度的字段总是被分配在一起,父类中定义的变量会出现在子类之前。可以设置参数使得子类的窄变量插入到父类变量的空隙中。
执行引擎
执行引擎将字节码指令解释/编译为对应平台上的本地机器指令。
JIT是动态编译,AOT是静态编译。JIT会将热点代码编译为机器码进行缓存。静态编译在程序运行之前就会将字节码转换为机器码。
静态编译的缺点:破坏了java“一次编译,到处运行”,必须为每个不同硬件、os编译对应的发行包。降低了Java链接过程的动态性,加载的代码在编译期就必须全部已知。
Java编译器有两种模式:一种是使用C1编译器的Client模式,一种是使用C2编译器的Server模式。C1编译器会对字节码进行简单和可靠的优化,耗时短;C2编译器会进行耗时较长的优化以及激进优化,效率高,默认使用。
C1编译器的优化:方法内联;去虚拟化:对唯一的实现类进行内联;冗余消除。
C2编译器的优化:标量替换:将对象拆解成若干个个成员变量;栈上分配:对未逃逸的对象分配在栈而不是堆;同步消除:清除同步操作。标量替换和栈上分配都是基于逃逸分析的开启,栈上分配也是先将对象进行标量替换后再放到栈中。
垃圾回收
引用计数算法的最大问题是无法处理循环引用的问题。
内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费。
可达性分析算法的根节点主要是虚拟机栈引用的对象,此外还有本地方法栈、类静态属性、方法区中常量应用、同步锁、Java虚拟机内部的引用。
由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面那它就是个Root。
常见的垃圾清除算法有:标记-清除、复制、标记-整理。新生代的S0和S1使用复制算法、老年代除了CMS使用标记-清除外,都使用标记-整理算法。
System.gc()
进行的是FullGC,但不一定马上执行。
当一个对象首次考虑要被回收时,会调用其finalize()
,主要是用于在对象回收前检查是否释放拥有的资源。但并不一定马上执行。
内存泄漏的情况:静态集合类;单例模式;内部类持有外部类;各种连接,如数据库连接、网络连接和IO连接等;变量不合理的作用域;改变哈希值;缓存泄漏;监听器和回调。
内存泄漏的原因是长生命周期的对象持有短生命周期对象的引用,尽管短生命周期的对象不再使用但是因为长生命周期对象持有它的引用而导致不能被回收。
STW:可达性分析算法中枚举根节点(GC Roots)会导致所有Java执行线程停顿,被称为是Stop the World。
程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始 GC,这些位置称为“安全点(Safepoint)。通常选择一些执行时间较长的指令作为Safe Point。
强引用:不回收;软引用:内存不即回收;弱引用:发现即回收;虚引用:对象回收跟踪。
ParallelGC在JDK6之后成为默认GC,G1在JDK9成为默认GC。
Serial采用复制算法、Serialold采用标记-整理算法,均为串行回收。
ParNew采用并行回收,只针对新生代,除此之外与Serial并无区别。默认搭配的老年代是SerialOld。当老年代使用CMS,默认的新生代是ParNew。
ParallelGC与ParNew的提升在于:可控制的吞吐量和自适应调节策略。
CMS是第一款真正意思上的并发垃圾收集器,采用标记-清除算法,主打是低延迟。CMS由于要给并发执行的工作线程预留内存空间,因此当堆内存使用率达到一定阈值便开始回收。
CMS使用标记-清除算法,而不使用标记-整理算法是因为避免对并发的工作线程造成影响。
CMS的缺点在于:会产生内存碎片、对CPU资源敏感、无法处理浮动垃圾。
官方给G1设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起“全功能收集器”的重任与期望。
G1将堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代,同时使用并发和并行。区域之间是复制算法,整体是标记-压缩。可预测的停顿时间。
G1面向服务端应用,针对具有大内存、多处理器的机器。
性能调优
dump文件以.hprof
结尾,不会覆盖,因此需要将旧文件先删除。
元空间OOM的解决方法:检测代码是否存在大量的反射操作,dump文件通过mat检查是否存在大量因反射生成的代理类。
Xmx
指定应用程序可用的最大堆大小,Xms
指定应用程序可用的最小堆大小。
GC overhead limit exceeded
和堆溢出的区别:这个是JDK6新加的错误类型,一般都是堆太小导致的。Sun官方对此的定义:超过98%的时间用来做GC并且回收了不到2%的堆内存时会抛出此异常。本质是一个预判性的异常制抛出该异常时,系统没有真正的内存溢出。
即使缩小了每个线程的大小,但是创建的线程数还是一样的,因为操作系统对一个进程内的线程数是有限制的。
对象的使用仅限于方法内部则没有发生逃逸,看new的对象实体是否有可能在方法外被调用。
命令行参数
命令 | 作用 |
---|---|
jps | 查看正在运行的Java程序 |
jstat | 查看JVM统计信息 |
jinfo | 实时查看和修改JVM配置参数 |
jmap | 导出内存映像文件和内存使用情况 |
jstack | 打印JVM中线程快照 |