JVM
JVM对于Java代码而言,做了很多事情,其中最主要的工作个人认为是在两个层面。
其一:内存管理
先谈JVM的运行时内存吧,这个东西基本上面试都会问到,但是对于日常开发,基本上不会过多的注意,但是想要写出复杂、高级的代码,对这块还是要有一些基本的理解。
这是一张基于Java1.8的JVM运行时内存图
共有部分即所有的线程都能访问,但是私有是每个线程自己独享的。
对于各个区域除了程序计数器以外,都会出现OOM即内存溢出的情况
- JVM虚拟机栈:栈的大小是有上限的,默认是1M大小,所以创建过多的局部变量可能撑爆栈空间,导致OOM;并且每一次压栈(可以简单理解为调用方法)都会消耗栈空间,因为每次压栈都可能会创建局部变量、操作数栈、动态连接等信息,这些都是需要占用栈空间的容量。过多的压栈操作可能导致StackOverflowError栈内存溢出。
- 本地方法栈:与JVM虚拟机栈类似
- 堆内存:堆内存可以简单理解为存放new出来的对象的地方,所以一旦超过堆内存的上限就会导致OOM,这里就涉及到最喜欢问又非常不实用的JVM垃圾回收,后文再表。
- 元空间:类被加载后存储的位置,基于Java1.8,永久代方法区统统被元空间包括取代,元空间使用的本地内存,所以空间很大,但是再大也有上限,同样会OOM
接下来聊聊JVM的垃圾回收过程
标准八股预备起:堆分为新生代和老年代,大小默认1:2,可调整,命令不记得,自己搜下;新生代又再划分为8:1:1的伊甸区(eden)和两个幸存者区(Survivor);
垃圾回收首先要知道什么是垃圾,JVM有两种方法发现垃圾,引用计数法、可达性分析
听着高大上,引用技术法就是对于创建的对象,维护一个引用次数字段,被引用一次+1,不为0就不是垃圾,为0就当垃圾回收;但是这个方法有个问题,A对象被B对象引用,但是B对象又A对象引用了,其他对象又不引用这两痴男怨女,就会导致这两一直纠缠,不会被当成垃圾处理掉;再说得直白一点,被问到背出来就行了,老古董,早没人用了。
可达性分析通过GC Roots来解决循环引用的问题,先解释下GC Roots是什么东西,一般由JVM虚拟机栈中的局部变量、元空间的静态变量、常量池的引用、本地方法栈的引用和直接内存中的某些区域来作为GC roots。通过遍历这些GC roots来分析对象可不可达,上述A-B相互引用的情况,如果没有GC roots能达到A-B,A、B也会被标记成垃圾,明白了没。
知道怎么发现垃圾、那么就要开始清理垃圾,清理垃圾由有一下几种垃圾回收算法
- 标记清除
- 标记复制
- 标记整理
- 分代回收
标记就是上面通过可达性分析发现垃圾的过程,标记清楚就是直接将标记的垃圾干掉,将内存释放出来,简单粗暴,会出现一个什么问题呢,打个比方,家里贴的地砖,有一些坏了,你直接给他敲了,那么现在的地面肯定一块好一块坏的,相互交错。为什么要说这个,是因为后面说到对象的创建过程会提到一个内存分配,后文再表。标记清除这么的简单粗暴,导致场面不好看,那么标记复制就是对他的增强,上文不是提到新生代又分为一个伊甸区和两个幸存者区么。在伊甸区内将标记的垃圾处理掉,剩下存活的对象就会被放到幸存者区的某一个,第一次两个幸存者区都是空的,所以没什么好说的,第二次这么操作,又冒出来一个from区和to区的概念,假设第一次回收后剩下的来的对象被放在幸存者区1,第二次就会将1里面和伊甸区剩下的放到幸存者区2,这样1、2分别就是from区和to区,下一次就要反过来,能理解吗?不知道谁定义的,挺扯的。注意,进入幸存者区的对象不是就长生不死了啊。幸存者区的内存空间也是有限的,当空间满了它也会进行GC的,这里再补充一个概念,经历GC又没有被回收的对象,它的age会+1,这个age>15的时候,它会被放进老年代,这个age的存放位置是在对象头里面,对象头是什么东西,后文再表。
分个段,上面那段写的太长了,标记复制这么看是不是挺好,每次都能整出来一个空的、连续的伊甸区内存空间,但是注意啊它这么搞一直有两个幸存者区得不到利用、只是单纯的作为容器存对象了,所以标记复制的毛病就是会导致部分内存得不到充分利用。标记整理登场,这货呢相当于标记复制之后,将内存进行一次排队,被占用的内存排一块,空闲出来的排一块,能理解吧。因为有这么整理内存的操作,所以他的缺点就是耗时长(复制的操作就不耗时了?但是他说这是缺点,你就记着)
分代收集就是新生代采用标记复制,老年代采用标记清除或者标记整理(重点记忆),Java1.8就是采用得分代收集算法
垃圾回收算法还有别的,但是这些应付面试应该够了,其他需要得自己再去找资料学习去吧
接着往下说,垃圾回收算法可以说是方法论,那么要让其变成可行得方案,就又要提到垃圾回收器
简单理解就是垃圾回收器对上述的理论进行了实现,约等于要写个方法计算a+b=c,你写了个方法达成了这个目的,这个比喻能理解吧。
,上面是新生代,下面是老年代
Jdk8默认使用的是Parallel Scavenge GC+Parallel Old GC
1、 两个收集器间有连线,表明它们可以搭配使用:Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1;
2、其中Serial Old作为CMS出现"Concurrent Mode Failure"失败的后备预案。
3、(红色虚线)由于维护和兼容性测试的成本,在JDK 8时将Serial+CMS、ParNew+Serial Old这两个组合声明为废弃(JEP 173),并在JDK 9中完全取消了这些组合的支持(JEP214),即:移除。
4、(绿色虚线)JDK 14中:弃用Parallel Scavenge和SerialOld GC组合 (JEP 366)
5、(青色虚线)JDK 14中:删除CMS垃圾回收器 (JEP 363)
主要对这个的理解也有限,不输出浅薄的个人理解了。
JVM的运行内存以及一些相关知识就介绍到这里。
其二:类的加载
JVM通过类加载器将类从磁盘、网络等地方加载到JVM运行时内存当中,类加载器后续再表。
先谈一谈类的加载过程
-
第一步:加载
- 在加载步骤,JVM会将编译的字节码文件加载到JVM运行时内存的方法区,1.8之后就是元空间
-
第二步:连接
-
连接又分三步:
- 校验:检验字节码文件是否合法,这个设计到字节码,本人水平有限说不清楚,想深入了解可以找找资料,可以参考尚硅谷
- 准备:为类的静态变量分配内存初始化默认值,这里有个东西后面再说
- 解析:将类中接口、方法等的符号引用转为直接引用
-
-
第三步:初始化
- 创建实例(new),对静态变量
-
第四步:使用阶段
-
第五步:卸载
对于静态变量的初始化,会在连接阶段的准备步骤,分配内存,基础类形就设置默认值,引用类形置为null,但是后续在初始化环节,可能会覆盖这个默认值。假设代码static int a = 1;准备阶段会给a分配内存,设置为0(int类型的默认值),初始化阶段JVM执行类的方法才会被赋值为1。
如果静态变量是final修饰的,准备步骤就会被赋值为1,初始化阶段会根据是否有具体的值,再决定要不要赋值。如果有值,初始化阶段不会做任何操作。
对于非静态变量,在类加载过程中不会被初始化,非静态变量是与类的实例相关联。
对于类的加载过程而言,类加载器起到一个搬用工的作用,当然加载过程也是由类加载器实现的。
类加载器的级别由高到低分别为:
1、启动类加载器BootstrapClassLoader
2、扩展类加载器ExtClassLoader
3、应用程序类加载器AppClassLoader
4、用户自定义类加载器CustomerClassLoader
说到这里又要引入一个新的概念:双亲委派机制
即进行类加载时,类加载器不会立即加载,而是委托他的父类进行加载,相当于应用类加载器加载某个类,会先交给扩展类加载器加载,扩展类又交给启动类,启动要是找到了类,直接返回该类,找不到就会由扩展类再尝试,找到直接返回,找不到应用类加载器自己加载这个类。
为什么要这么做?
- 避免类的重复加载:每个类只会被加载一次,因为父类加载器会先尝试加载,确保了类的唯一性。
- 保证核心类库的安全:系统类加载器无法加载核心库(如java.*包下的类),这些类由引导类加载器加载,保证了JVM的基础运行环境不受影响。
- 模块化的隔离:开发者可以通过自定义类加载器实现特定的加载逻辑,而不会影响到JVM内置的类加载机制。
提一嘴,类加载过程是线程安全的,由JVM保证线程安全。
现在填一个坑,类既然已经加载了,那么就可以创建对象了,开new。
创建对象实例的时候,首先要在堆内存中为对象分配内存空间,这空间大小是怎么确定的呢,根据对象头+对象实例+对其填充来决定需要的内存大小
对象头包括以下部分:
-
对象标记MarkWord
① 哈希值
② GC分代年龄
③ 锁状态标志
④ String类还有字符串长度
-
类型指针:类信息
对象实例包括:相关属性,如果继承父类,还有父类属性(含私有)
对齐填充:填充,可有可无,保证是加起来是8字节的倍数
对象的创建步骤如下
第零步:检查类有没有加载
第一步:为对象分配内存
有两种算法,这个跟之前的垃圾回收有关
对象碰撞:标记复制、标记整理GC之后的内存是连续有序的,直接再空闲内存的头部取用足够的内存空间
空闲列表:标记清除的内存空间是不连续的,对于清理出来的空闲内存维护一个空闲列表,使用时从中选取足够的内存空间,并从列表中移除被使用的内存地址
第二步:初始化默认值
第三步:对象头设置
第四步:构造器调用
第五步:引用赋值
第六步:垃圾回收
再提两个概念:
TLAB:多线程环境下对象的创建可能存在线程安全问题,这是由垃圾收集器(G1、CMS)提供的一种内存分配优化技术,线程开始时JVM为每个线程分配一个初始的TLAB内存区域,这个内存区域是从新生代扣的。当线程需要需要创建新对象时,先在自己的TLAB区域分配内存,因为每个线程都有自己的,所以不用加锁竞争,提高了效率。如果TLAB区域不足,会尝试扩容,扩容失败,JVM会采用其他措施(具体是啥我也不知道)。线程结束,这个线程的TLAB会被释放。
逃逸分析:对象不是绝对会分配在堆上,对于局部变量,根据作用域的分析可能会被分配在栈上。