1、JVM内存模型
(1)、理论知识点
- 线程共享:垃圾回收机制的场所只发生在线程共享区域(大部分发生在堆上),即生命周期与Java进程相同。
- 方法区(非堆,内存可调整,回收主要针对常量池和类的卸载,不会走垃圾回收算法,也会垃圾回收)
- 静态变量
- 即时编译后的代码(不包含方法的出参入参)
- 元数据/类的描述文件(类的版本、字段、方法、接口等相关信息描述)
- 常量,运行时常量池,存储编译器产生的字符串常量和符号引用(类被加载的信息)
- 堆
- string,类对象的属性值,当对象实例在堆中分配好以后,需要在栈中保存一个对象的引用指针。堆处于物理不连续的内存空间中,只要逻辑上连续即可。
- 方法区(非堆,内存可调整,回收主要针对常量池和类的卸载,不会走垃圾回收算法,也会垃圾回收)
- 非线程共享:当线程终结时,所占的内存空间也会被释放掉,即生命周期与所属线程相同。
- 程序计数器
- JVM栈
- 操作码,方法内部编译后的代码(包含方法的出入参)
- 操作数,方法内的局部变量(boolean、byte、char、short、int、float、long、double、对象的引用指针),局部变量表所需要的内存空间在编译期间完成分配,在方法运行之前,内存空间是固定的,运行期间也不会改变。
- 本地方法栈
- 与Java栈类似,主要提供native方法服务。
(2)、什么是实例什么是对象?
- Class a = new Class();a是实例是引用保存在栈上占4字节,new 出来的Class是对象保存在堆上。
(3)、堆栈的理解?
- 栈中的数据和堆中的数据销毁并不同步。方法一旦结束,栈中的局部变量立即销毁,但是堆的对象不一定销毁,因为可能有其他变量也在使用这个对象,直到所有在线程中(栈)都不引用这个对象,则才会被垃圾回收机制销毁。
- 堆中对象可以全局访问,栈内存空间属于线程私有。
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出栈溢出;堆内存不够用抛出内存溢出。
(4)、方法区和栈都能存放类的方法信息,有什么联系?
- 类的方法是一个类所有对象共享的,只有一套,保存在方法区,当被对象使用的时候才会被压入栈。
(5)、方法区和永久代的区别?
- 方法区是jvm中的一个概念,永久代是方法区的一个具体的实现(jdk1.6之前)
(6)、jdk1.8之后,用元空间取代了永久代,为什么这样做?
- 永久代,存的是常量、静态变量、即时编译后的代码
- 元空间以后,常量、静态变量存在堆里,元空间只存即时编译后的代码(元数据)
- 这样做极大的避免了方法区内存溢出,现在改为元空间,就存在本地机器的内存上,故可以根据机器性能指定元空间的大小。
(7)、对象一定分配在堆上吗?
- 不一定,也可能分配在栈上。
- 逃逸分析算法。参考
(8)、什么是逃逸分析?
- 一种算法,如果对象没有被其他线程引用,即没有逃逸,就在栈上分配;如果对象被其他线程引用了,即逃逸了,就在堆上分配。
- 怎么判断对象逃逸?看这个对象有没有被其他线程所引用,被引用就逃逸。
2、内存分配
(1)、创建对象有哪些方式?
- new对象
- 反射
(2)、为类创建对象的过程?
- 类加载检查:jvm收到new的指令时,检查类的元数据是否在常量池中,检查该类是否已经被加载、解析初始化过,若没有则需要执行类加载
- 为对象分配内存:
- 通过逃逸分析,确定对象是在堆上分配还是在栈上分配(逃逸分析默认是不开启的)
- 栈上分配:说明该对象只会被一个线程访问,没有并发问题
- 堆上分配:JVM在新生代Eden Space中开辟了一小块线程私有的区域,称作TLAB(Thread-local allocation buffer),默认占Eden的1%。由于不是线程共享的,所以小对象分配在这里比较好。
- 如果TLAB可以放下该对象,则直接分配,否则2
- 在Eden区加锁,若可以放下该对象,则分配,否则3
- 执行Young GC,再尝试在Eden上分配,若可以放下该对象,则分配,否则4
- 直接分配到老年代
- 总结:对象不在堆上分配的主要原因是堆事共享的,需要考虑并发问题,有一定的开销,TLAB分配和栈分配是线程私有的,避免了资源的竞争,但是会有数据间可见性的问题(空间换效率)。
- 通过逃逸分析,确定对象是在堆上分配还是在栈上分配(逃逸分析默认是不开启的)
- 将内存空间初始化为零值:int、boolean等基本数据结构赋初始值
- 对对象进行必要的设置:在对象头中设置改对象属于那个类的实例
(3)、垃圾回收算法
- 垃圾回收器是标记压缩、复制算法:堆是规整的,采用指针碰撞的方式分配内存
- 垃圾回收器是标记清理算法的:堆是不规整的,采用空闲列表的方式分配内存
3、对象头(Mark Word)
(1)、对象在内存怎么存储的?存储区域有哪些?
- 对象头
- 实例数据
- 对其填充,8的倍数
(2)、对象头的结构?
1.32位操作系统
- 25Bits对象的hashcode
- 4Bits对象分代年龄
- 2Bits锁标志
- 1Bits标记是否是偏向锁,默认值为0
2.64位操作系统
- 无锁:25bit(没有用到) + 31bit(hashcode) + 1bit(cms_free) + 4bit(分代年龄) + 1bit(偏向锁) + 2bit(锁标识位)
- 偏向锁:54bit(线程id) + 2bit(Epoch时间戳版本的意思) + 1bit(cms_free) + + 4bit(分代年龄) + 1bit(偏向锁) + 2bit(锁标识位)
(3)、synchronized锁住的是代码还是对象?
- 因为对象头上有锁标志位,故锁的是对象
(4)、synchronized锁状态(只能升级)
- 无锁:代码不加锁
- 偏向锁:
- 参考别人的解释:
- 当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID
- 因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致
- 如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;
- 如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活
- 如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;
- 如果存活,那么立刻查找该线程(线程1)的栈帧信息
- 如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁
- 如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。
- 整体过程:
- 当线程访问同步块获取锁时过程中,会更新锁对象对象头信息,线程栈会记录锁信息
- 怎么生成的?对象头又一个ThreadId字段,若字段是空,当线程第一次获取到锁的时候就将当前线程的ThreadId写到这个字段内,是否偏向锁状态设置为1。
- 当线程尝试获取锁时,会检查锁对象的对象头锁状态
- 若是无锁状态,则获取到锁并且在锁对象的对象头记录线程id和偏向锁标志;
- 若是偏向锁状态,则对比对象头记录的线程id与当前线程的id是否一致,
- 若一致则成功获取到锁;
- 若不一致,检查锁对象记录的线程id是否存活,
- 若不存活,则标记锁对象状态为无锁状态,其他线程(当前线程)可以获取到偏向锁;
- 若存活,则根据锁对象的线程id检查该线程是否需要继续持有该锁对象
- 若无需持有则标记锁对象为无锁状态,其他线程(当前线程)可以获取到偏向锁
- 若继续持有则暂停锁对象的线程,撤销偏向锁,升级为轻量级锁。
- 当线程访问同步块获取锁时过程中,会更新锁对象对象头信息,线程栈会记录锁信息
- 有哪些作用呢?下一次获取锁的时候,之间检查该对象头中的ThreadId和当前线程的ThreadId是否一致,若一致说明已经获取到锁。这样就略过了轻量级锁和重量级锁的加锁阶段,提高了效率。
- 参考别人的解释:
- 轻量级锁:CAS+失败重试,失败过多升级为重量级锁
- 参考别人的解释:
- 线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;
- 如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。
- 但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。
- 重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。
- 注意:为了避免无用的自旋,轻量级锁一旦膨胀为重量级锁就不会再降级为轻量级锁了;偏向锁升级为轻量级锁也不能再降级为偏向锁。一句话就是 锁可以升级不可以降级,但是偏向锁状态可以被重置为无锁状态。
- 参考别人的解释:
- 重量级锁:我们常用的那种锁。
(5)、Integer对象的大小是int的几倍?
- Int 4字节
- Integer共计16字节:4字节数据+4字节markword+4字节指针+4字节对齐补充(8的倍数)
4、JVM整体结构
5、类加载
(1)、类加载机制
1、加载:
- 描述:把类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,创建一个java.lang.Class对象,用于封装类在方法区内的数据结构。
- 补充:
- 通过类的全限定名(包名+类名)来获取定义此类的二进制字节流(Class文件)。获取方式(jar、war、网络中抓取)
- 将字节流中的数据转化为方法区运行时数据结构,保存在方法区中。
2、连接
- 验证:加载的类在方法区的运行结构是否正确,是否符合虚拟机要求,确保不会有安全问题。
- 准备:
- 静态变量赋初始值,例如 代码中写到static int a = 100,这里a=0
- 静态常量赋值,例如 代码中写到 static final int b = 100,这里b=100
- 对于成员变量是在类实例化的时候,随对象一起分配在堆中。
- 解析:将类的二进制数据中的符号引用换为直接引用。
3、类的初始化
- 静态变量赋值,例如上一步的a=0,这里a=100
4、小总结
- 使用new字节码指令创建类的实例,调用一个static静态方法的时候
- 反射调用类的时候
- 初始化一个类的时候,会先初始化他的父类,若发现其父类没有初始化,则初始化父类。对于静态字段,只有定义这个字段的类才会被初始化,因此,通过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化。
- 虚拟机启动时,需要产生一个主类(包含main()方法的类)
class A{
static{
sout("init")
}
public static final String P = "123";
}
public class B{
psvm(){
sout(A.P);
}
}
输出结果:123
结论:调用常量不需要初始化
原因:静态常量在类连接的时候就已经赋值了,所以在B中不会触发A的初始化操作。
(2)、双亲委派模型
1、什么是双亲委派?
- 类在加载的时候,需要通过类的加载器完成,分别是自定义类,应用类加载器,拓展类加载器,引导类加载器,当JVM需要加载一个类的时候,会委托给父加载器去做,然后父类加载器会在自己的加载路径中搜索目标类,若找不到才会还给子类去加载。我们平时写的代码都属于应用类(不属于jdk自带的)。
2、java为什么用双亲委派?双亲委派的意义?
- 安全性,保护jdk提供的一些核心类库布被覆盖
- 避免类重复加载
3、怎么破坏双亲委派?
- 线程上下文类加载器Thread.currentThread().getContextClassLoader()
- 把loadClass重写掉(只重写findClass不会破坏双亲委派)
4、为什么要破坏双亲委派?
- java中存在很多服务提供者接口(SPI),这些接口需要第三方为它提供实现,如JDBC、JNDI等,这些SPI接口属于java核心类库,存在rt.jar中,由根加载器加载,由于根加载器无法直接加载SPI的实现类,所以需要线程上下文类加载器去做这件事情。
5、如果我重写了系统的jar包,例如rt.jar,jvm还能加载成功吗?
- 不能,安全校验过不去
6、如果我有两个A.class,那么加载到内存中两个class一定是相同的吗?
- 不一定,还得考虑加载他们的加载器是否相同,若不通,则两个class是不同的(不同类加载器加载出来的对象是不同的)
7、为什么用自定义类加载器?自定义加载器可以做什么?
- 加密:java代码可以反编译,可以把自己的代码进行加密以防止反编译,可以将编译后的代码加密,类加密后就不能再用java的classLoader去加载了,这时就需要自定义ClassLoader在加载类的时候,先解密再加载。
- 从非标准的来源加载代码:UDF
- 处理网络传输的加密后的java字节码
8、java提供JDBC接口,那么其SPI是怎么加载的?
- java有DriverManager类,其中有个方法loadInitialDrivers,通过ServiceLoader.load(Driver.class)加载外部实现的驱动类,ServiceLoader会读取取mysql jar包的META_INF文件内容加载,类似于SpringBootSPI的方式
- 核心包SPI类对外部实现类的加载都是基于线程上下文类加载起执行的,通过这种方式实现了Java核心代码内部去调用外部实现类。
9、什么是ContextClassLoader,哪来的?为什么可以在任何时候从当前线程中取出?
- jvm加载的时候,会讲AppClassLoader设置到当前线程中,把该加载器设置为全局的,这样在任何时候需要用到ContextClassLoader时都可以从当前线程中获取到
- ContextClassLoader只用于加载java的SPI接口实现类库。
(3)、tomcat类加载器结构
1、tomcat为什么要破坏双亲委派?
- 保证部署在同一个tomcat web容器下的多个应用之间所使用的一些java类库相互隔离。
- 也要保证部署在同一个tomcat web容器下的多个应用所使用的一些java类库可以共享,避免jvm虚拟机方法区过度膨胀。
- 容器本身也有自己的核心库,tomcat web容器也需要把自身类库单独放在一个地方保护起来。
2、Web应用类加载起默认的加载顺序是?
- 先从缓存中加载
- 若无,从JVM的Bootstrap类加载器加载
- 若无,从当前类加载器加载(WEB-INF/classes、WEB-INF/lib的顺序)
- 若无,从父类加载起加载,顺序是,AppClassLoader、Common、Shared
6、小总结小技巧
(1)、怎么看GC日志
- YoungGC(MinorGC)
- FullGC(MajorGC)
(2)、GC是什么?
- 分代收集算法
- 次数上频繁收集新生代
- 次数上较少收集老年代
- 基本不动永久代/元空间
(3)、为什么FullGC比YoungGC慢很多?
- FullGC发生在老年代,老年代空间比新生代要大(2:1)
(4)、垃圾回收器,以及对应的有哪些算法?
1.CMS
1>.引用计数法
- 算法描述:当有一个对象引用我时,会在计数器上+1,当计数器为0是,说明没被对象引用,故需要清理
- 缺点:每次对对象赋值时需要维护引用计数器,较难处理循环引用(持有并等待,哲学家思考问题)
2>.复制算法
- 算法描述:年轻代使用,from区(包含eden)复制到to区。因为在该区域,对象存活率低,所以比较适合用,若存活率比较高,则不适合使用,当达到60%会直接放到老年代。
- 原理思想:将内存分为两块,每次只用其中的一块,当这一块内存用完就开始垃圾回收,并且把还存活的数据复制到另一块空的内存上面。
- 优点:不会产生内存碎片
- 缺点:耗空间,空间没有充分利用
3>.标记清除
- 算法描述:老年代使用,先标记要回收的对象,然后统一回收这些对象。
- 原理思想:先埋点,后清除
- 优点:节约空间,充分利用空间
- 缺点:产生内存碎片,需要先标记后才能清除,速度上会慢点,效率低。
4>.标记压缩
- 算法描述:先标记,在清除,后压缩整理
- 原理思想:在标记清除的基础上在整理下碎片。
- 优点:节约空间,没有碎片
- 缺点:耗时比较长。
2.G1垃圾收集算法
https://github.com/doocs/jvm
https://docs.oracle.com/javase/8/docs/technotes/tools/#troubleshoot
https://github.com/vipcolud/monitor
[https://github.com/yikebocai/blog/issues/32](