JVM
1.JVM体系结构
2.类加载器
3.双亲委派机制
3.1什么是双亲委派机制
当某个类加载器需要加载某个.class文件是,它首先把这个任务委托给他的上级类加载器,递归这个操作,如果上级的类加载器没有加载,自己才会去加载这个类。
3.2类加载器的类别
-
BootstrapClassLoader(启动类加载器)
C++编写,加载Java核心库java.*,构造ExtClassLoader和AppClassLoader。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。
-
ExtClassLoader(标准拓展类加载器)
java编写,加载拓展库,如classpath中的jre,javax.*或者java.ext.dir指定位置中的类,开发者可以直接使用标准拓展类加载器。
-
AppClassLoader(系统类加载器)
java编写,加载程序所在的目录,如user,dir所在位置的class。
-
CustomClassLoader(用户自定义类加载器)
java编写,用户自定义的类加载器,可加载指定路径的class文件
委派机制流程图
3.3双亲委派机制的作用
- 防止重复加载同一个.class。通过委托去向上面问一问,加载过了,就不用再加载一遍了。保证数据安全。
- 保证核心.class不能被篡改。通过委托方式,不会去篡改核心.class,即使篡改也不会去加载,即使加载也不会是同一个.class对象了。不同的加载器加载同一个.class也不是同一个Class对象。这样保证了Class执行安全。
4.native和方法区
4.1native
- 凡是带了native关键字的,说明java的作用范围达不到了,会去调用底层C语言的库
- 会进入本地方法栈进行调用
- 调用本地方法接口 JNI
- JNI的作用:拓展Java的使用,融合不同的语言为Java所用! 最初:C、C++
- Java诞生的时候,C、C++横行,想要立足,必须要有调用C、C++的程序
- 它在内存区域中专门开辟了一块标记区域:Native Method Stack,登记 native 方法
- 在最终执行的时候加载本地方法库中的方法通过JNI
- 在抄作一些硬件的时候需要调用本地方法,如Java驱动打印机、管理系统,掌握即可,在企业级应用中较为少见
4.2方法区
- 方法区被所有现场共享,所有字段和方法字节码,以及一些特殊方法,如构造器,接口代码也在此定义,简单说,所有定义的方法的信息都保存在该区域,此区域属于共享区间
- 静态变量、常量、类信息(构造方法、接口定义),运行是的常量池存在方法区中,但是实例存放在堆内存中,和方法区无关
5.栈(Stack)
- 栈内存,主管程序大的运行,生命周期和线程同步
- 线程结束,栈内存释放,对于栈来说,不存在垃圾回收问题
- 内容:八大基本类型 + 对象引用 + 实例的方法
new一个对象的过程
6.堆(Heap)
-
一个JVM只有一个堆内存,堆内存大小是可以调节的
-
类加载器读取了类文件后,一般会把什么东西放在堆中? 类、方法、常量、变量、所有引用类型的真实对象
-
堆内存中还要分为三个区域:
-
新生区(伊甸园区)Young/New
-
养老区 old
-
永久区 Prim
-
-
GC垃圾回收,主要在伊甸园和养老区
-
假设内存满了,OOM,堆内存不够!java.lang.OutOfMemoryError:Java heap space
-
在JDK8以后,永久存储区改了个名字(元空间)
7.新生区、养老区、永久区、堆内存调优
7.1新生区
- 类诞生和成长,甚至死亡的地方
- 伊甸园区:所有对象都是在伊甸园区new出来的
- 幸存区(0,1),存放经过轻GC(GC)存活下来的对象
7.2养老区
- 存放经过重GC(Full GC)存活下来的对象
7.3永久区
这个区域常驻内存,用来存放JDK自身携带的JDK对象。interface元数据,存储的是Java运行时环境或类信息,这个区域不存在垃圾回收!关闭VM虚拟机就会释放这个内存区域
- jdk1.6之前:永久代,常量池在方法区
- jdk1.7 :永久代,但是慢慢退化了,
去永久代
,常量池在堆中 - jdk1.8之后:无永久代,常量池在元空间
一个启动类,加载了大量的第三方jar包。Tomcat部署了太多的动态应用,大量动态生成反射类,不断的被加载,直到内存满,就会出现OOM
7.4JDK1.8之后堆内存模型
元空间:逻辑上存在,物理上不存在
7.5OOM错误解决方案:
-
尝试扩大堆内存看结果
-Xms1024m -Xmx1024m -XX:+PrintGCDetails //mx : 最大内存 //ms :初始化内存 //默认情况下,分配的最大内存是电脑内存的1/4,初始化内存为电脑内存的1/64
-
分析内存,看哪个地方出现了问题(专业工具)
-
能够看到代码第几行出错:内存快照分析工具,MAT,Jprofiler
-
Debug,一行行分析代码
7.6MAT、Jprofiler的作用
- 分析Dump内存文件,快速定位内存泄露位置
- 获得堆中的数据
- 获得大的对象
- …
-XX:+PrintGCDetails //打印GC垃圾回收信息
-XX:+HeapDumpOnOutOfMemoryError //OOM dump
8.GC常用算法
8.1引用计数器
为每个对象分配一个计数器,引用一次对象计数器加1
不常用:维护计数器成本太高
8.2复制算法
-
新生区中的两个幸存区总有一个是空的,谁空谁是幸存to区,另一个为幸存from区
-
每次GC都会讲Eden活的对象和幸存from区活的对象移到幸存to区,一旦新生区被GC后,Eden就会是空的,由于幸存from区存活对象移至幸存to区,因此幸存from区与幸存to区交换,即from区为空,变为to区,to区变为from区
默认一个对象经历了15次GC后,还没有死,则进入老年代
通过参数 -XX: -XX:MaxTenuringThreshold 可以设定进入老年代的时间
好处:没有内存的碎片
坏处:浪费了内存空间:多了一半空间永远是空的(幸存to区)。极端情况(对象100%存活),移动对象消耗大
最佳使用场景:对象存活度较低的时候,新生区
8.3标记清除
优点:不需要额外的空间
缺点:两次扫描,严重浪费时间;会产生内存碎片
8.4标记压缩
对标记清除的改经:
8.5总结
内存效率:复制算法 > 标记清除算法 > 标记压缩算法(时间复杂度)
内存整齐度:复制算法 = 标记压缩算法 > 标记清除算法
内存利用率:标记压缩算法 = 标价清除算法 > 复制算法
没有最合适的算法,只有最合适的算法---->GC:分代收集算法
年轻代:存活率低---->复制算法
老年代:存活率高---->标记清除算法+标记压缩算法(混合实现)
9.JMM
- JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存放在主存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程读/写变量的副本
-
在JVM内部,Java内存模型把内存分成了两部分:线程栈区和堆区
9.1Java内存模型带来的问题
- 可见性问题----->使用Java volatile关键字或是加锁解决
- 竞争现象 ------>加锁解决
9.2Java内存模型中的重排序
- 编译优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
- 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行
9.3并发下重排序带来的问题
- 数据的不一致性
- 解决方案:
-
内存屏障——禁止重排序(volatile)
Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型处理器的重排序,从而让程序按我们预想的流程去执行
1.保证特定操作的执行顺序
2.影响某条数据(或者是某条指令的执行结果)的内存可见性
-
临界区(锁)
临界区内的代码可以进行重排序,但由于监视器互斥的特性,其余线程观测不到正在执行线程临界区内的重排序。这种重排序既提高了执行效率,有没有改变程序的执行效果
9.4Happens-Before
用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作的执行结果对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序必须在第二个操作之前。(对程序员来说)
- 两个操作之间存在happens-before关系,并不以为着Java平台的具体实现必须按照happens-before关系指定的顺序来执行。如果重排序后的执行结果与按happens-before关系来执行的结果一致,那么这种重排序是允许的。(对编译器和处理器来说)
- Happens-Befor规则(无需任何同步手段就可以保证)
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中任意后续操作。
- 监视器锁规则:对一个锁的加锁,happens-before与随后对这个锁的加锁。
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
- 传递性:如果A happens-before B,B happens-before C,则A happens-before C。
- start()规则:如果线程A执行ThreadB.start()(启动B线程),那么A线程的ThreadB.start()操作 happens-before于线程B中任意操作。
- join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中任意操作happens-before 线程A从ThreadB.join()操作成功返回。
- 线程中断规则:对线程interrupt方法的调用happens-before于被中断线程的代码检测到中断事件的发生。
9.5volatile内存语义
-
volatile变量自身具备以下特性
- 可见性。对一个volatile变量的读,总能够看到(任意线程)对这个变量最后的写入。
- 原子性。对任意当个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具备原子性。
-
volatile写的内存语义:当写一个volatile变量时,JMM会把该线程对应本地内存中的共享变量值刷新到主存中。
-
volatile读的内存语义:当读一个volatile变量时,JMM会把该线程对应本地内存中的共享变量值置为无效,线程接下来将从主存中读取共享变量。
-
volatile内存语义实现:
- 在每个volatile写操作前面插入一个StoreStore屏障,在每一个volatile写操作后面插入一个StoreLoad屏障。
- 在每个volatile读操作后面插入一个LoadLoad屏障,在每个volatile读操作后面插入一个LoadStore屏障。
9.6volatile实现原理
有volatile修饰的共享变量进行写操作的时候会使用CPU提供的Lock前缀指令。
- 将当前处理器缓存行的数据写回到系统内存。
- 这个写回内存的操作会使其他CPU里缓存了该内存地址的数据无效。
9.7锁的内存语义
- 当线程释放锁时,JMM会把该线程对应本地内存中的共享变量刷新到主存中。
- 当线程获取锁时,JMM会把该线程对应本地内存中的共享变量值置为无效,从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。
9.8synchronized的实现原理
使用monitorenter和monitorexit指令实现:
- monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorenter插入到方法结束处和异常处
- 每个moniterenter必须有对应的monitorexit与之对应
- 任何对象都有一个monitor与之关联,当且一个monitor被劫持后,它将处于锁定状态
9.9了解锁的各种状态
- 无锁状态
- 偏向锁状态:大多数情况下,锁不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。无竞争时不需要使用CAS操作来加锁和解锁。
- 轻量级锁状态:无竞争时通过CAS来加锁和解锁。
- 重量级锁状态
9.10final的内存语义
编译器和处理器要遵循两个重排序规则:
- 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
- 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
final域为引用类型:
- 增加了如下规则:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
final语义在处理器中的实现:
- 会要求编译器在final的写之后,构造函数return之前插入一个StoreStore屏障。
- 读final域的重排序规则要求编译器在读final域的操作前面插入一个LoadLoad屏障。