1. 虚拟机的基础概念
1.1 什么是虚拟机
我们有一个文件x.java–>执行javac–>变成x.class,当我们在终端中运行java xxx的时候,对应的.class文件会被装载到内存。一般的情况下我们写自己类文件的时候也会用到java的类库,所以它要把java类库相关类也装载到内存里,装载完成之后会调用字节码解释器或者是即时编译器来进行解释或编译。由于JVM编译模式有三种 (后面3.3 编译器会提到),这里只需要知道java默认使用混合编译即可,编译完之后由执行引擎开始执行,执行引擎下面面对就是操作系统的硬件了,这就是Java Virtual Machine该做的事。
1.2 流行的Java虚拟机
目前java虚拟机我们使用最多的是Hotspot。你可以在命令行中输入如下命令查看
java -version
它会输出你现在用的虚拟机的名字,执行模式,版本等信息。64位的 Server版 用的执行模式是mixed mode
1.3 从跨平台的语言到跨语言的平台
跨平台的语言:能在不同系统上运行的原因是JVM帮你屏蔽了这些操作系统的底层,我们编写的代码只是跑在JVM这个“程序”上,然后由JVM的规范去实现在不同的系统上运行。
跨语言的平台:java虚拟机怎么才能做到这么多语言都可以往上跑呢?关键的原因就是class这个东西,任何语言只要你能编译成class,符合class文件的规范你就可以扔在java虚拟机上去执行。所以,从jvm的角度来讲,它是不看你任何的语言的,只和class文件有关系,不管你是谁,只要你变成class,我就能执行。
2. class文件结构
整个class文件的格式就是一个二进制字节流,这个二进制字节流是由java虚拟机来解释的。整个class文件由以下几部分组成:
-
Magic Number,俗称魔数,在Java的class文件中即指cafe babe。
-
Minor Version(小号)/Major Version(主号),用它来表示当前JDK版本。
-
constant_pool_cont(有多少个常量池),常量池是class文件里最复杂的内容,而且常量池里会互相引用,以及常量池会被其他的各方面引用,所以常量池是极其复杂的。constant_pool_count-1:常量池是从1开始的,数组是从0开始,所以要减去1.从1开始的原因是:它有一个0存在于那里,将来没准哪天有些引用指向,表示不指向任何常量池的一项,那时候就可以用0来表示,保留了一个可能性。
-
access_flags,指的是整个class你前面写的是public还是privote等什么东西
-
this_class,我当前的这个类是谁
-
super_class,父类是谁
-
Interfaces_count,实现了哪些接口
-
Interfaces,接口的索引
-
fields,有哪些属性
access_flags,是不是public的,是不是static的,是不是final的???
-
name_index,名称的索引
-
descriptor_index,描述符,到底是什么类型的
-
attributes,另外它所附加的一些属性,有的有,有的没有
-
methods,方法的各种结构,到底是怎么样进行标识的,它名字的索引、描述符的索引、附加属性
对于如何查看class文件内容可以参考这篇文章,里面介绍了三种工具的使用JVM-查看class文件的工具Binary viewer、ue、classlib Bytecode viewer
3. 内存加载过程
3.1 Classloader概念
任何一个class都是被ClassLoader load到java虚拟机,这个ClassLoader 其实有一个顶级父类就叫ClassLoader ,他是一个abstract抽象类。JVM它本身有一个类加载器的层次,这个类加载器本身就是一个普通的class,JVM有一个类加载器的层次分别来加载不同的class,JVM所有的class都是被类加载器加载到内存的,而这个加载类的类加载器可以叫做ClassLoader。
对于类加载器有以下四个层次:
- 第一个类加载器层次
最顶层的Bootstrap,加载lib里jdk最核心的的内容比如说rt.jar charset.jar等核心类。所以说当我调用getClassLoader()拿到这个加载器的结果是一个空值的时候代表的你已经到达了最顶层的加载器。 - 第二个类加载器层次
Extension加载器扩展类,加载扩展包里的各种各样文件,这些扩展包在jdk安装目录jre/lib/ext下的jar。 - 第三个类加载器层次
平时用的加载器application,他用于加载classpath指定的内容。 - 第四个类加载器层次
自定义加载器ClassLoader,加载自己自定义的加载器。
类加载器范围:
- sun.boot.class.path
- BootstrapClassLoader加载路径
- Java.ext.dirs
- ExtensionClassLoader加载路径
- java.class.path
- AppClassLoader加载路径
如果你想知道你的class是被谁弄到内存的,在程序中使用getClassLoader()方法即可:
System.out.println(String.class.getClassLoader());
3.2 Loading–>LinKing–>Initializing
-
Loading:把一个class文件load到内存里去,他本来是class文件上的一个一个的二进制,一个一个的字节,装完之后就是接下来Linking。
-
Linking:Lingking阶段又分为三步
- verification,校验装进来的class文件是不是符合class文件的标准。
- preparation,把class文件静态变量赋“默认值”,不是赋初始值,比如你static int i = 8,并不是把8赋值给i,而是先赋成0。
- Resolution,把class文件常量池里面用到的符号引用,给它转换为直接内存地址,直接可以访问到的内容。
-
Initializing:静态变量这时候赋值才成为“初始值”。
示例:
public class T04 {
public static void main(String[] args) {
//查看是谁load到内存的,执行结果null,为什么为空呢,因为Bootstrap使用c++实现,Java里并没有class和他对应
System.out.println(String.class.getClassLoader());
//这个类是位于ext目录下某个jar文件里面,当你调用它执行结果也就是sun.misc.launcher$ExtClassLoader
System.out.println(sun.net.spi.nameservice.dns.DNSNameService.class.getClassLoader());
//这个是我们自己写的ClassLoader加载器,它是由sun.misc.launcher$AppClassLoader加载的
System.out.println(T04.class.getClassLoader());
//一个Ext的ClassLoader调用他的getclass(),他本身也是一个class,然后调用他的getClassLoader,他的ClassLoader又是谁,就这个ClassLoader的ClassLoader是我们最顶级的ClassLoaderBootstrap,执行结果为null
System.out.println(sun.net.spi.nameservice.dns.DNSNameService.class.getClassLoader().getClass().getClassLoader());
//思考结果
System.out.println(T04.class.getClassLoader().getClass().getClassLoader());
}
}
3.3 编译器
-
解释器:- bytecode intepreter
-
JIT:-Just In-Time compiler
-
混合编译模式(默认)
编译和解释模式的区别:
- -Xmixed 默认为混合模式,开始解释执行,启动速度快,对热点代码实行检测和编译
- -Xint 使用纯解释模式,启动很快,执行稍慢
- -Xcomp 使用纯编译模式,启动很慢,执行很快
热点代码检测:
多次被调用的方法(方法计数器:监测方法执行频率)
多次被调用的循环(循环计数器:监测循环执行频率)
3.4 懒加载 lazyloading
- 严格来讲应该是lazyInitializing
- JVM的规范并没有规定何时加载
- 但是严格规定了什么时候必须初始化(五种情况)
- new对象时
- 访问对象时
- 反射调用时
- 虚拟机调用主类启动时主类必须初始化
- 初始化子类时,父类必须先初始化
- 但是严格规定了什么时候必须初始化(五种情况)
3.5 定义自己的ClassLoader
- 继承ClassLoader
- 重写模板方法findClass
- 调用defineClass
- 自定义类加载器加载class的好处:
- 防止源码被反编译
- 防止篡改
3.6 双亲委派机制
为什么要搞双亲委派? 主要是为了安全,如果任何一个class都可以把它load内存的话,那我就可以给你java.lang.string,我交给自定义ClassLoader,把这个string load进内存,打包给客户,然后把密码存储成string类型对象,我可以偷偷摸摸的把密码发给我自己,那就不安全了。
双亲委派的就不会出现过这样,自定义ClassLoader加载一个java.lang.string他就产生了警惕,他先去上面查有没有加载过,上面有加载过直接返回给你,不允许你重新加载。
推荐查看这篇博客通俗易懂的双亲委派机制
4. JVM内存结构
4.1 Programm Counter
每个 Java 虚拟机线程都有自己的程序计数器(寄存器)。Java虚拟机线程执行当前代码时,如果该方法不是 native,则pc寄存器会包含当前正在执行的 Java 虚拟机指令的地址。如果线程当前正在执行的方法是native,则 Java 虚拟机的pc 寄存器的值是未定义的。
4.2 JVM Stack
每个 JVM线程都有一个与其同时创建的私有JVM Stack。它保存局部变量和部分结果,并在方法调用和返回中发挥作用。由于除了推送和弹出帧外,Java 虚拟机堆栈永远不会被直接操作,因此帧可能是堆分配的。所以JVM Stack的内存不需要是连续的。
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构。它存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息:
- 局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。
- 操作数栈(Operand Stack)是一个后入先出栈(LIFO)。随着方法和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。
- 动态链接:Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态链接(Dynamic Linking)。
- 方法返回:无论方法是否正常完成,都需要返回到方法被调用的位置,程序才能继续进行。
JVM可以让程序员或用户控制 JVM Stack的初始大小,以及在动态扩展或收缩 Java 虚拟机堆栈的情况下,控制最大和最小大小。以下异常情况与 Java 虚拟机堆栈相关:
- 如果线程中的计算需要比允许的更大的 Java 虚拟机堆栈,则 Java 虚拟机会抛出StackOverflowError.
- 如果 Java 虚拟机堆栈可以动态扩展,并且尝试进行扩展,但没有足够的内存来实现扩展,或者如果没有足够的内存来为新线程创建初始 Java 虚拟机堆栈,则 Java Virtual机器会抛出OutOfMemoryError.
4.3 Native Stack
支持native方法(用 Java 编程语言以外的语言编写的方法)以使 Java 虚拟机指令集的解释器实现其他语言运行。不加载native方法并且本身不依赖传统栈的Java虚拟机实现不需要提供本地方法栈,如果提供,本地方法堆栈通常在创建每个线程时为每个线程分配。
4.4 Heap
堆的目的就是存放对象。几乎所有的对象实例都在此分配。垃圾收集器就是收集这些对象,然后根据GC算法回收。
现代垃圾收集器大部分都是基于分代收集理论设计,堆空间细分如下:
-
Java 7及之前堆内存逻辑上分为三部分:新生区+养老区+永久区
– Young Generation Space 新生区 Young/New 又被细分划分为 Eden 区和 Survivor区,Eden区占大容量,Survivor两个区占小容量,默认比例是8:1:1。
– Tenure generation space 养老区 Old/Tenure
– Permanent Space 永久区 Perm -
Java 8及之后堆内存逻辑上分为三部分:新生区+养老区+元空间
– Young Generation Space 新生区 Young/New 又被细分划分为 Eden 区和 Survivor 区
– Tenure generation space 养老区 Old/Tenure
– Meta Space 元空间 Meta
jdk8开始,使用元空间(MetaSpace)取代了永久代。
4.5 Method Area
它是一个供所有 Java 虚拟机线程之间共享的方法区,在虚拟机启动时创建。它存储的是已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。尽管方法区在逻辑上是堆的一部分,但简单的实现可能不会选择进行垃圾收集或压缩它。需要注意的是:
在jdk7及以前,习惯上把方法区,称为永久代。jdk8开始,使用元空间(MetaSpace)取代了永久代。永久代和元空间都是方法区的实现。
5. JVM常用指令
字符含义
- i代表int类型的数据
- l代表long类型的数据
- s代表short类型的数据
- b代表byte类型的数据
- c代表char类型的数据
- f代表float类型的数据
- d代表double类型的数据
- a代表reference类型的数据
5.1 Idc
从运行时常量池提取数据并压入操作数栈。
5.2 invoke
调用实例初始化方法比如<init>方法。
5.3 dup
复制操作数栈栈顶的值,并插入到栈顶。
5.4 pop
弹出栈顶端指定字长的内容。
5.5 算数和逻辑指令
//加法
iadd、 ladd、 fadd、 dadd
//减法
isub、lsub、fsub、dsub
//乘法
imul、lmul、fmul、dmul
//除法
idiv、ldiv、fdiv、ddiv
//求余
irem、lrem、frem、drem、
//求负值
ineg、lneg、fneg、dneg
//移位指令
ishl、ishr、iushr、ishl、lshr、lushr
//按位或指令
ior、lor
//按位与指令
iand、land
//按位异或指令
ixor、lxor
//局部变量自增
iinc
//比较指令
dcmpg、dcmpl、fcmpg、fcmpl、lcmp
5.6 store
将一个数值从操作数栈存储到局部变量表的指令
istore、lstore、fstore、dstore、astore
5.7 load
将一个本地变量加载到操作数栈
iload、lload、fload、dload、aload
6. GC与调优(重点)
6.1 什么是垃圾以及垃圾的产生
垃圾就是你先分配了内存,后来这块儿内存不用了,使得没有任何引用指向一个对象,成了占用内存空间的垃圾。java有自己的垃圾回收器实现自动回收。
6.2 java/c/c++分配和垃圾回收对比
Java | C | C++ | |
---|---|---|---|
分配 | 申请new | 申请malloc | 申请new |
回收 | 自动回收 | 释放free | 释放内存delete |
C&&C++手动回收缺点
- 忘记回收:申请了,但是忘记回收了,内存泄漏。
- 多次回收:释放了两次,第二次是把别人申请的数据给回收了。
6.3 常用的垃圾回收器
6.4 垃圾定位算法
6.4.1 Reference Count
给对象添加一个引用计数器,每当有一个地方引用它时,计数器值加1。当失去一个引用时,计数器值减1,引用数量为0的时候,则说明对象没有被任何引用指向,可以认为是“垃圾”对象。
但是这种定位算法存在一个缺点:不能解决循环引用的问题。比如O1->O2->O3->O1,当没有引用指向他们任何一个的时候,他们的reference count都是1,从算法来看,他们都不是垃圾。然而没有任何引用指向O1、O2、O3这个循环链当中的一个,所以他们应该都是垃圾才对。
6.4.2 Root Searching
Reference Count不能解决的循环引用问题,可采用RootSearching。该算法通过一系列名为 GC Roots 的对象作为根,从根处开始搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是垃圾。那么哪些是 Roots 呢?
- 线程栈变量,Java程序从main方法开始执行,main方法会开启一个线程,这个线程里有线程栈,里面有栈帧。从main开始这个线程栈帧里面的这些东西叫做根对象。
- 静态变量,一个class被load到内存之后,马上就对静态变量进行初始化,此时静态变量访问到的对象也是根对象。
- 常量池,如果一个class能够用到其他的class的对象,那么他就是根对象。
- JNI指针,本地方法用到本地的对象也是根对象。
6.5 垃圾回收算法
6.5.1 Mark Sweep
先对垃圾做标记,然后清除有标记的垃圾。如果存活的对象比较多,这种算法的执行效率比较高。相反执行效率就稍微低一点。容易产生内存碎片使内存空间不连续。
6.5.2 Copying
把内存一分为二,比如A、B两个区域,分开之后,把A区域中有用的拷贝到B区域,拷贝完成后,把A区域全部清除,下次再分配内存的时候先往B区域分配,如此往复。
该算法适用于存活对象较少的情况,只扫描一次,效率有所提高并且不会产生内存碎片。但要注意移动复制对象时须调整对象引用。
6.5.3 Mark-Compact
先把没有引用指向的对象标记为垃圾,然后把后面存活的对象依次有序拷贝到有标记的地方进行覆盖,最后就会剩下空闲内存空间。
和第一种算法一样需要扫描两次,算法效率虽然有点低但没有内存碎片产生且不浪费内存空间
6.6 对象分配过程
6.6.1 栈上分配
不可否认,大部分的对象创建时都是分配到堆内存里面的,但是呢也有特例,以hotspot虚拟机为例,hotspot虚拟机在创建对象时,会先判断创建的对象符不符合栈上分配的条件,如果符合,那么这个对象就会分配到栈上,否则就分配到堆上。
虽然说栈内存是属于子线程和基本数据类型专用的内存空间,创建对象默认都是分配到堆内存,但是也可能分配到栈内存,也算是hotspot的一种优化技术,而且这是一种比较极端的方式,一般情况下不这么干,只要满足逃逸分析的条件之后,创建的对象就会分配到栈内存了,怎么理解逃逸分析呢?
就是当你在方法里 new 出一个对象之后,这个对象能不能逃出当前的方法在其他地方被使用,如果对象不能逃出当前方法,就代表着满足了逃逸分析的“不可逃逸”条件,逃逸分析的条件有2个:
- 对象没有被存入堆中(静态字段或者堆中对象的实例字段),一旦对象被存入堆中,其他线程便能获得该对象的引用,即时编译器就无法追踪所有使用该对象的代码位置。如类变量或实例变量,可能被其它线程访问到,就叫做线程逃逸,可能会存在线程安全问题。
- 对象没有被传入未知代码中,即时编译器会将未被内联的代码当成未知代码,因为它无法确认该方法调用会不会将调用者或所传入的参数存储至堆中,这种情况,可以直接认为方法调用的调用者以及参数是逃逸的。也就是说当一个对象在方法中定义之后,它可能被外部方法所引用,作为参数传递到其它方法中,这叫做方法逃逸
满足了以上2个条件了之后,创建出的对象才会分配到栈内存,否则就会分配到堆内存。
关于逃逸分析的优化可以参考这篇文章
6.6.2 TLAB(Thread Local Allocation Buffer)
为什么会有TLAB?
• 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据。通过TLAB让JVM为每个线程分配一个私有缓存区域,这个私有缓存区包含在Eden区域内。一旦对象在TLAB空间分配内存时失败,JVM就会尝试着通过使用加锁机制直接在Eden空间中分配内存,以确保数据操作的原子性。尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。
• 由于对象实例的创建在JVM中十分频繁,在一次并发环境下从堆区中划分内存空间是线程不安全的。
• 为避免多个线程操作同一地址,需要使用加锁等机制,会影响分配速度。多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以讲这种内存分配方式成为快速分配策略
• 在程序中,TLAB默认开启,开发人员可以通过选项“-XX:UseTLAB”设置是否开启TLAB空间。
• 默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过选项“-XX:TLABWasteTargetPercent”设置TLAB空间所占用Eden空间的百分比大小。
总的来说,考虑到数据操作原子性、线程安全问题以及对象分配速度,使用TLAB这种分配方式可以避免许多不必要的问题。