Java 可以实现一次编写,到处运行,这是因为 JVM 帮我们处理不同硬件之间的差异
JVM的作用
- 生成平台无关的字节码,在操作系统以上做一层隔离;
- 让编程语言设计的更灵活,比如支持垃圾回收等;
- 有更多的优化手段。
2000年JDK1.3:Hotspot作为默认虚拟机发布
2002年JDK1.4:Class VM 退出历史舞台
下面所讲的都是 Hotspot 虚拟机的特性
1、JVM组成的5部分
JVM的内存布局(主要学习JDK 8以及以后的布局)
1.1 堆【线程共享】
所有的对象实例以及数组都放在了这个区域( 垃圾回收器回收的都是堆里面的内容),堆也是JVM中最大的一块内存,如果堆里面的内存满了就会导致OOM
-
新生代:新创建的数据会在新生代,当经历了一定次数的GC之后依然存活下来的数据会移动到老年代,HotSpot默认的垃圾回收次数是15 次(新生代的默认年龄是15,每次经过一次垃圾回收如果还没有被回收,年龄会+1),新生代里面的数据GC频率高
新生代又分为三个区域: Eden,s0和s1 每一次使用的时候都是Eden+s0 或者 Eden+s1配合使用,在后面的垃圾回收算法里面会直到具体的使用方式
-
老年代:存放的是经过了一定次数还存活的对象和大对象,GC频率低
面试题:为什么大对象会直接放到老年代?
答:因为大对象的创建和销毁所需要的时间比较多,所以性能也比较慢,如果存到新生代,那么可能导致频繁的创建和销毁大对象,从而导致整个JVM运行速率的降低,所以就直接将大对象存到老年代。(用空间换取时间)
如果在堆中没有足够的内存完成实例分配并且堆也无法再拓展时,将会抛出OOM
-Xmx10m 设置的时对空间最大值为10mb
-Xms 10m 设置堆空间的最小值, 一般和上面的值设置的是相同的,防止内存扩容的时候发生内存抖动
ms(Memory Stsrt)
1.2 JVM栈 (Java虚拟机栈)【线程私有】
先进后出
从上图可以看出,JVM栈由四部分组成
- 局部变量表:8大数据类型和对象的引用信息(局部变量表所需要的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在执行期间不会改变局部变量表大小。)
- 操作栈:每个方法都会生成的栈
- 动态连接:指向字符串常量池
- 方法返回地址:PC寄存器的地址
1.3 程序计数器【线程私有】
程序计数器的内存空间比较小,用来记录线程执行的行号(程序计数器内存区域是唯一一个在JVM规范中没有规定任何OOM情况的区域!)
1.4 本地方法栈【线程私有】
和JVM栈类似,只不过JVM栈是给Java程序使用的,而本地方法栈是给本地方法(C/C++)使用的
1.5 元数据区(元空间,JDK8之后才有的)【线程共享】
元数据区在JDK8之前是叫做 永生代 或者 方法区,并且这两部分属于JVM,到JDK 8的时候这个区域就从JVM里面移出去了,保存在本地内存里面了。静态属性,字符串运行时常量池,常量final,类元信息放在本地内存里面。 方法区无法满足内存分配的需求的时候会抛出OOM异常
字符串常量池之前是在方法区的,在JDK 7之后将字符串常量池放在了 堆 里面
2、JVM类加载过程(Class Loading)
类加载器简言之,就是用于把.class文件中的字节码信息转化为具体的java.lang.Class对象的过程的工具。
- 加载:将类权限名的二进制流加载到内存
- 验证:验证加载的信息是否符合JVM规范
- 准备:加载静态变量赋值初始化
- 解析:将符号引用替换为直接应用,也就是将常量进行初始化
- 初始化:JVM将执行权交给应用程序,此时要执行类的初始化方法
2.1 双亲委派模型
类加载必须符合双亲委派模型 , JDK1.2开始就有的双亲委派模型
JVM加载类的时候,子类并不会直接进行类加载,而是将任务将给父类,一层一层的进行传递,如果在父类中找不到这个子类,自己才会尝试加载
2.1.1 双亲委派模型的优点
- 保证类的唯一性 Object -> rt.ja
- 安全性
2.1.2 破坏双亲委派模型(什么情况下会导致双亲委派模型的破坏)
- JDK 1.2 引入双亲委派模型,为了兼容老代码,出现了第一次破坏双亲委派
- 它是双亲委派模型自身的缺点所导致的第二次破坏,比如当父类出现了调用子类的方法的时候
- 人们对于热加载和热更新的追求,导致了第三次双亲委派模型的破坏
3、JVM垃圾回收器
- 判断死亡对象
- 垃圾回收算法
- 垃圾回收器
3.1 判断一个对象的死亡
3.1.1 引用计数器算法
给每个对象生成一个对应的计数器,每当有一个地方引用它的时候这个计数器+1,如果撤销引用时计数器-1,GC会根据计时器的值,当此值等于0的时候就可以判断此对象为死亡对象。
JVM没有使用引用计数器来管理内存, 引用计数器缺点:不能解决循环引用的问题
循环引用的示例:
设置内存打印:
public class JVMDemo2 {
private Object obj = null;
private Object[] objects = new Object[2*1024*1024]; //2m大小的对象
public static void main(String[] args) {
JVMDemo2 j1 = new JVMDemo2();
JVMDemo2 j2 = new JVMDemo2();
//循环引用
j1.obj = j2;
j2.obj = j1;
//解除引用
j1 = null;
j2 = null;
//强制垃圾回收(发一次请求)
System.gc();
}
}
[GC (System.gc()) 21586K->816K(249344K), 0.0013503 secs] //新生代,大对象会直接进入老生代
[Full GC (System.gc()) 816K->673K(249344K), 0.0039860 secs] //老生代
从上面的代码可以看出来并没有使用引用计数器的算法进行垃圾回收,因为引用计数器的算法不能解决循环引用的问题,而上面的代码不存在循环引用的问题
-X:执行的非标准的JVM参数,只有在部分HotSpot虚拟机下才能用
-XX:标准的JVM参数,可以适用于所有的HotSpot虚拟机
-D:设置应用程序的参数
3.1.2 可达性分析算法(目前JVM使用的判断对象生死的算法)
通过一系列称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索走过的路径称之为"引用链",当一个对象到GC Roots没有任何的引用链相连时(从GC Roots到这个对象不可达)时,证明此对象是不可用的。
在Java语言中,可作为GC Roots的对象包含下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI(Native方法)引用的对象
在JDK1.2以前,Java中引用的定义很传统 : 如果引用类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。这种定义有些狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态。
我们希望能描述这一类对象 : 当内存空间还足够时,则能保存在内存中;如果内存空间在进行垃圾回收后还是非常紧张,则可以抛弃这些对象。很多系统中的缓存对象都符合这样的场景。
在JDK1.2之后,Java对引用的概念做了扩充,将引用分为强引用(Strong Reference)、软引用(SoftReference)、弱引用(Weak Reference)和虚引用(Phantom Reference)四种,这四种引用的强度依次递减
- 强引用:当发生了OOM(内存泄漏),垃圾回收器也不会回收此对象
- 软引用:比强引用稍弱,发生OOM之前会对软引用进行回收
- 弱引用:比软引用更弱,在下一次垃圾回收的时候就会清除
- 虚引用:创建即消亡,他的价值只是在垃圾回收的时候触发一个回调方法
3.2 垃圾回收算法
3.2.1 标记清除算法(可以作为老生代算法)
- 标记:可达性分析算法
- 缺点:内存碎片
3.2.2 复制算法(新生代回收算法)
先将存活的对象复制到另一个没有使用的内存中,然后将此内存全部清除(Eden,s1,s0),这是新生代主流的垃圾回收算法,复制收集算法在对象存活率较高时会进行比较多的复制操作,效率会变低。因此在老年代一般不能使用复制算法。
- 优点:性能比较快,新生代的垃圾回收算法
- 缺点:内存的利用率不高
新生代中98%的对象都是"朝生夕死"的,所以并不需要按照1 : 1的比例来划分内存空间,而是将内存(新生代内存)分为一块较大的Eden(伊甸园)空间和两块较小Survivor(幸存者)空间,每次使用Eden和其中一块Survivor(两个Survivor区域一个称为From区,另一个称为To区域)。当回收时,将Eden和Survivor中还存活的对象一次性复制到另一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor
空间。
3.2.3 标记整理算法(老生代回收算法)
可以解决内存碎片的问题,通常在老生代里面使用
- 标记:使用的是可达性分析算法来进行标记
- 整理:将存活的对象存放在头部,
3.3 垃圾回收器(7种垃圾回收器)
上图中如果两个收集器之间存在连线,就说明他们之间可以搭配使用。
并行(Parallel) : 指多条垃圾收集线程并行工作,用户线程仍处于等待状态
并发(Concurrent) : 指用户线程与垃圾收集线程同时执行(不一定并行,可能会交替执行),用户程序继续运行,而垃圾收集程序在另外一个CPU上。
吞吐量:就是CPU用于运行用户代码的时间与CPU总消耗时间的比值
-
serial:新生代、单线程、串行的垃圾回收器,需要停止整个应用来进行垃圾回收,并且停止的时间较长(复制算法)
-
ParNew:新生代、Serial的多线程版本,并行GC,除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数、收集法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一样,在实现上,这两种收集器也共用了相当多的代码 (复制算法)
-
Parallel Scavenge:新生代、并发垃圾回收器(并行GC),用户线程和GC可以一起执行(复制算法)(以牺牲STW的时间为代价,比较适用于纯后端系统)
-
serial Old:老生代、单线程、串行的垃圾回收器(标记整理算法)
-
Parallel Old:老生代的并发(并行GC)垃圾回收器(标记整理算法)(以牺牲STW的时间为代价,比较适用于纯后端系统)
-
CMS:老生代、并发执行的垃圾回收器,尽量的压缩了程序的停止时间,所以适用于前后端交互的B/S系统CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求(标记清除算法)(JDK8之前的主流的垃圾回收器,目前应用最广泛的垃圾回收器)
实现短时间的STW,暂停进行垃圾回收的时间很短(只有初始标记和重新标记的时候暂停),通常在BS里面使用
CMS执行流程四步:初始标记(STW),并发标记,重新标记(STW),并发清理
-
G1:JDK11默认的垃圾回收器,唯一一款全区域的垃圾回收器
G1垃圾回收器回收region的时候基本不会STW,而是基于 most garbage优先回收(整体来看是基于"标记-整理"算法,从局部(两个region之间)基于"复制"算法) 的策略来对region进行垃圾回收的。
4、JMM(Java内存模型)
JVM定义了一种Java内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下能达到一致的内存访问效果。
内存间相互操作:
- lock(锁定) : 作用于主内存的变量,它把一个变量标识为一条线程独占的状态
- unlock(解锁) : 作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取) : 作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load(载入) : 作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用) : 作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎。
- assign(赋值) : 作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量。
- store(存储) : 作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便后续的write操作使用。
- write(写入) : 作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
Java内存模型的三大特性 :
- 原子性
- 可见性:final,synchronized,volatile
- 有序性
volatile
- 保证此变量对所有线程的可见性
- 使用volatile变量的语义是禁止指令重排序
volatile 禁止重排的三个场景:
编译器重排序 -> JVM重排序 -> 内存重排序