深入理解java虚拟机(JVM)------一篇过

2021/7/18 12:05:57

本文主要是介绍深入理解java虚拟机(JVM)------一篇过,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

JVM——一篇过

发展史(不知道历史的程序员不是一个好秃子)

前身:Oak语言(91年开发消费性电子产品)

java me、java se、java ee

java me:移动终端java程序

java se:桌面级应用

java ee:企业家应用,包含了许多扩展包

JDK、jre、jvm

jvm:java虚拟机是运行java程序的,是实现跨平台

jre:包括jvm和java程序所需的核心类库,主要包含lang包

jdk:提供给开发人员使用的,包含了jre、java开发工具(javac.exe、jar.exe…)

年代史

95年,1.0发布—–>98年jdk1.2即时编译器——>04年jdk1.5(泛型、枚举等)——>08年sun被oracle收购74亿——>14年1.8lambda表达式,彻底移除永久代——>18Androidjava侵权赔88亿(相当公司白送的)

自动内存管理

在这里插入图片描述

程序计数器

线程私有的一个字节码行号指令器,当执行native方法时,为空(唯一一个不报空指针异常

栈(线程私有)

jvm栈

java方法调用时分配空间存储局部变量等,用完就释放

本地方法栈

执行native方法时分配空间存储变量

堆(线程共享)

存放对象实例(new 对象,静态对象,1.7加入了静态变量和常量池),也是GC管理的区域,也叫GC堆

方法区(线程共享)

存储class、常量、静态变量

jdk1.7时将字符串常量和静态变量移到堆中,jdk1.8废除方法区的概念,改用元空间来存储class

元空间

jdk1.8时出现用来代替方法区的

为什么要用元空间

传统的方法区是占用jvm的内存的,是有内存溢出的风险,但是方法区是占用本地内存,是由电脑的内存决定,所以空间很大,基本不会出现内存溢出的情况

运行时常量池

属于方法区,即在程序运行时将一些常量加载到池中,常用的一种是:String.intern()

直接内存(了解)

不是jvm运行数据区,默认和最大堆内存一样大,在jdk1.4时,引入NIO,NIO利用native方法堆外内存,可以通过DirectByteBuffer对象对直接内存操作,避免了java堆和native堆的来回复制,即Netty(基于NIO实现的)的零拷贝性质

对象的创建(new 对象)

直接上图:类加载后面会讲,别问我怎么设置对象头啥的,有的东西记住就行了,就像我tm抽你,你问为什么,不为什么,你只要知道你被打了就行

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RO9qq6ys-1626579613814)(C:\Users\liuguangcheng\AppData\Roaming\Typora\typora-user-images\image-20210717130246604.png)]

内存分配的两种方式

指针碰撞

假设堆内存左边是利用了的,右边是空闲的,中间有一个指针,分配内存时,将指针向右挪动对象内存大小,当使用Serial、ParNew收集器时(空间压缩整理)用这种内存分配方式

空闲列表

使用的内存和空闲的交错在一起,就需要维护一个列表,当分配内存时,将一个足够容纳对象大小的内存分配给它就行,当使用CMS(清除)收集器时用这种方式分配内存

后面会讲到垃圾收集器!!

对象内存布局

对象头

两部分:

  1. hashcode、GC分代年龄、锁的信息
  2. 堆向指向它的类型元数据的指针

数据部分

不仅包含了自己的数据,还包含了父类继承下来的字段信息

对齐填充(非必然存在)

HotSpot虚拟机要求对象大小是8字节的整数倍,不是的话就要通过对其填充来补全

对象的访问

  1. 句柄访问:通过句柄池找到对象指针,在通过指针找到对象

  2. 直接指针:直接通过对象的指针访问,快速效率高

理解不了?看下面例子

例如:你想找个老婆,你可以找媒婆帮你找一个,你也可以直接找一个,但是呢,结婚以后突然你老婆跑了,那你怎么办,在找一个呗,这时候找媒婆的优势就来了,省时省力,直接叫她在帮你找一个,但是你自己找的老婆呢,就又要自己辛辛苦苦去找

内存溢出(OOM)和内存泄露(ML)

内存泄露:无用对象(垃圾)的内存不释放,导致这部分回收不了,一直占着

内存溢出:在程序申请内存时,内存不够,出现的一种异常,内存泄露的后果就是内存溢出

垃圾收集和内存分配策略

垃圾回收

无用对象的回收叫垃圾回收,怎么判断是不是垃圾呢?

  1. 引用计数算法:对象添加引用计数器,每次有对象引用改对象时,就会+1,引用失效-1,计数为0时就是可以回收的,该算法看是简单,实际却需要很多额外处理,所以基本没有虚拟机用该算法
  2. 可达性分析:通过“GC Roots”作为根节点集,跟据引用链向下搜索,没有在引用链上的对象就是可回收对象

四大引用

  1. 强引用:Object o=new Object()这种,垃圾回收永远不会回收
  2. 软引用:java类中有一个SoftReference来实现软引用,在内存溢出之前会被回收
  3. 弱引用:java提供了WeakReference来实现,垃圾回收时就会回收掉该对象
  4. 虚引用:PhantomReference,唯一的目的是为了在该对象被回收时收到一个通知

回收过程

当判定一个对象是不可达时不会被马上回收掉的,必须经过连个阶段

  1. 不可达判断为true,第一次标记
  2. 对象是否执行了finalize方法,如果执行就代表已经被回收过了,如果没有就放入一个F-Queue,稍后会对queue中第二次标记,将标记两次的对象由一个线程优先级低的Finalizer线程去执行finalize方法回收对象

垃圾回收算法

标记清除算法

标记需要回收的对象后,统一回收

缺点:

  1. 执行效率不稳定,回收对象过多效率会降低
  2. 内存碎片化问题

标记复制算法

将对象回收后的存活对象复制到另一个等大的内存中,将原来的空间清理,在将两块空间对换,该算法多用于新生代回收算法

缺点:对空间的浪费

优点:解决了内存碎片化的问题,且新生代朝生夕灭,存活对象少,所以效率很高

标记整理算法

将对象回收后的空隙进行整理,多用于老年代的回收算法

缺点:整理式对象移动必须全程暂停用户应用程序,否则导致指针混乱,就像你数鸭子,鸭子一直跑,着你怎么数

优点:由于老年代的对象都是老干部,都是经过风吹雨打存活下来的,所以比较少对象会被回收,所以整理的代价还是比较小的

HotSpot算法实现细节

根节点枚举

根节点枚举必须暂停所以线程,可达性分析时,并不需要1个不漏的遍历,HotSpot利用了一个OopMap数据结构来存放对象引用,在特定位置记录栈和寄存器的引用

安全点

OopMap在特定位置记录时的特点位置就是安全点,所以线程执行到安全点后就开始垃圾收集

安全区域

在某一段区域的任意地方进行垃圾收集都时安全的

垃圾回收器

Serial收集器

早期的**“单线程”(新生和老年垃圾回收时暂停所以工作)**新生代垃圾回收器,对于运行在客户端模式的虚拟机时一个不错的选择

ParNew收集器

Serial的多线程(新生代多线程)版本,但是垃圾回收时,依旧需要暂停所以工作

Parallel Scavenge收集器

是一个重吞吐量的新生代多线程垃圾回收器

Serial Old收集器

Serial的老年代版本收集器,也是一个单线程

Parallel Old收集器

多线程Parallel Scavenge的老年代版本,不能和CMS配合

CMS收集器

以最短回收时间停顿的收集器,内存足够时,使用标记清除,不足时用标记整理

过程
  1. 初始标记(停止工作)
  2. 并发标记
  3. 重新标记(停止工作)
  4. 并发清除

G1收集器

面向服务端的全堆垃圾收集器,哪块存放的垃圾最多,回收效益最大,着就是Mixed GC模式

ZGC收集器

利用染色体指针来实现低延迟垃圾回收器

染色体指针:一种可扩展的存储结构用来记录更多与对象标记、重定向过程相关的数据

实战

  1. 对象优先分配在Eden:大多数情况下,对象都是分配在Eden新生代的
  2. 大对象直接进入老年代:大对象的垃圾回收很耗费资源,所以要避免过多大对象进入新生代
  3. 长期存活的对象将进入老年代(默认是年龄15)
  4. 动态对象年龄判定:相同年龄数量大于总数一半,这些相同年龄就应该直接进入老年代,否则可能会导致新生代回收效率低下
  5. 空间分配担保:发生minor GC之前,会去检查老年代可用空间是否大于新生代总对象空间,大于是安全的,根据概率赌不会使老年代空间不够而发生full GC

类加载机制

在这里插入图片描述

特例:解析过程可能会在初始化之后(动态绑定或晚期绑定)

6种情况必须初始化

  1. new 、getstatic、putstatic、、invokestatic指令时
  2. new实例化对象时
    1. 定义该数组类,不会初始化
  3. 对类型进行反射时
  4. 父类为初始化时,先初始化父类
    1. 调用父类的静态变量,子类不会初始化
    2. 使用常量池的变量,不会初始化
  5. 1.8加入default接口实现类初始化,接口也要初始化
  6. 方法句柄没有初始化时

加载

  1. 利用非数组的类的全限类名来获取二进制字节流
  2. 将字节流转换到方法区(1.8之后的元空间)中
  3. 生成一个class

数组类加载:数组类不通过类加载器加载创建,而是直接在内存动态构造

验证

保证class文件字节流符合约束,且不会对jvm造成危害

  1. 文件格式验证
  2. 元数据验证
  3. 字节码验证
  4. 符号引用验证

准备

static变量初始化并赋原始值(int赋0),而真正的赋值是在构造器中赋值

解析

将常量池的符号引用替换为直接引用

初始化

执行类构造器()方法的过程

类加载器

一个类的全限类名二进制字节流放到jvm外部,让程序直接去获取需要的类

双亲委派机制

每次都会将类给父类加载器去尝试加载,加载不了才会给子类加载

顺序:启动类加载器——>扩展类加载器——>应用程序类加载器——–>自定义类加载器(必须继承抽象类ClassLoader)

img

被破坏过3次

  1. jdk1.2才提出双亲委派机制,所以1.2之前的并不符合
  2. 线程上下文加载器:父类加载器去请求子类加载器完成类加载
  3. 模块化热部署时会破坏

程序编译和代码优化

编译器

  • 前端编译器:javac编译器将.java编译成.class文件
  • 即时编译器:C1、C2,Graal编译器将字节码变成本地机器码
  • 提前编译器:ATO编译器把程序编译成与目标机器指令想关的二进制代码

优化

前端编译器(前端优化):对代码基本无优化,但是对编码效率和使用等有比较多的优化手段(语法糖等)

即时编译器(后端优化):几乎大代码有话都放到该阶段完成,运行期优化可使一些可移植class也享受到优化

编译过程

1个准备、3个处理过程

  1. 准备过程:初始化插入注解处理器
  2. 解析填充符号表过程
    • 语法、词法、填充符号表
  3. 插入式注解处理器处理注解过程
  4. 分析与字节码生成过程
    • 静态信息检查、动态运行检查、解语法糖、字节码生成

泛型

Java的实现方式是类型擦除式泛型

类型擦除

  1. 运行期原始泛型派生对原始类型的继承关系
  2. 编译器直接抹除(例:ArrayList —–>ArrayList)
既然抹除了泛型,为什么反射还能取得参数化类型?

答:虽然在字节码抹除了泛型,但是在元数据还是保留了泛型信息

后端编译和优化

即时编译器

作用:将字节码中的**热点代码(多次调用的方法、多次调用的循环体)**编译成本地机器码

  • C1编译器:又叫客户端编译器
  • C2编译器:又叫服务端编译器
  • Graal编译器:JDK10出现来替换C2的编译器,现在不清楚

热点探索

  1. 采样:周期性检查栈顶,经常在栈顶的方法(J9)
  2. 计数器:每次调用就+1(HotSpot)

热度衰减

一定时间内,调用次数不够即时编译器门槛,就会在垃圾回收时随便计数器减半

提前编译器

2分支作用:

  1. 本质时给即时编译器做缓存加速
  2. 静态提前编译(在系统空闲时编译)

即时编译器对提前编译器的优势

  1. 性能分析制导优化:热点代码集中优化和分配更好的资源
  2. 激进预测性优化:性能监控支持概率预测优化
  3. 链接时优化:动态链接

编译器优化技术(前沿技术)

对中间代码或机器码进行代码优化,而不是对源码优化

方法内联

将方法体替换方法调用,增加了代码量,但是减少了地址引用

逃逸分析

对象逃出原本的作用域(return 对象)

公共子表达式消除

表达式计算过,且过程变量不变,则直接使用

数组边界检查消除

1.编译期完成边界检查 2.隐式异常处理

一些普通的优化

常量迭代、算数聚合、符号合并……

Graal编译器

展望替换HotSpot成为一款高效编译、高质量输出、支持即时编译和提前编译、支持不同虚拟机的编译器、继承HotSpot的高质量优化技术

java内存模型(JMM)与线程

硬件效率与一致性

处理器的熟虑与内存的速度根本不是一个等级,这样就限制了我们的效率,所以引入了高速缓存的存储交互(但是带来了缓存一致性问题)

JMM

目的:定义程序中各种变量的访问规则

在这里插入图片描述

怎么解决缓存一致性问题:

在这里插入图片描述

volatile

  1. 可见性:解决内存屏障带来的不可见问题,每次读取之前会去主存中查看最新的值,判断变量是否过期,过期的话就会从主存读取最新的值,每次写操作时将缓存行数据立即写入主存,且让其他其他CPU缓存了改变量的地址无效(简单来说就是每次使用前向主内存主刷新
  2. 禁止指令重排:在并发的情况下,JVM的指令重排优化可能会对变量的操作产生影响,这是就需要禁止指令重排来避免

内存屏障

由于编译器的优化和缓存的使用,导致对内存的写入操作不能及时的反应出来,也就是说当完成对内存的写入操作之后,读取出来的可能是旧的内容

long、double(64位)的非原子协议

对于64位数据类型,虚拟机会将它的读写操作分为3次32位的操作,这样就不能保证操作的原子性了

例如:两个线程同时去对long或double进行读取和修改,可能即得不到原来的值,也得不到修改后的值

实际开发:除非你明确知道该类型变量有线程竞争,否则没有必要去加volatile关键字

线程实现(了解)

Thread的所以关键方法都声明位Native,即本地方法

三种实现方式

  1. 内核线程实现(1:1)一般不会直接使用内核线程
  2. 用户线程(1:n):我们常用的Thread线程的建立即与他操作都在用户态完成,不需要内核帮助,程序设计好就不需要切换导内核态,高速低消耗
  3. 混合实现:内有轻量级线程作为桥梁,对状态的切换等提高很多,也可以支持大规模的用户线程并发

状态转换

  • new 创建未使用
  • Runable:
    • Running:运行时
    • Ready:准备运行,等待CPU分配时间片,阻塞状态获取导锁后就进入准备运行状态
  • Waiting:无限等待被其他线程唤醒,在等待池中
  • Timed Waiting:休眠,等时间过来就会苏醒,典型的sleep方法(sleep期间不会释放对象的锁)
  • 阻塞:等待获取一个排他锁,等待线程被唤醒后就加入阻塞状态

线程安全和锁优化

线程安全

当一个线程操作变量时,其他的线程不会对该变量的操作造成影响

  1. 不可变变量:String 、枚举、final修饰的变量、java.lang.Number的部分子类(Long、Double…)
  2. 绝对线程安全:不管什么环境,都是线程安全的
  3. 相对线程安全:大部分线程安全集合类(Vector、hashMap….)等

锁优化

JDK5—->JDK1.6加入了很多的锁优化手段:适应性性自旋、锁消除、锁膨胀、轻量级锁、偏向锁

自旋锁

默认开启,在获取锁失败后,会自旋重试来获取锁

锁消除

对被检测导明确不会存在锁竞争的锁进行锁消除来提高运行效率,锁消除的根据还是逃逸分析技术(前面讲过了)

锁粗化

一串零碎的锁操作对同一个对象,加锁的范围扩展导整个操作序列的外部

轻量级锁

对象头(mark word)记录一些的对象的非数据信息,里面就包含了锁标志等,轻量级锁锁升级后会变成重量级锁

偏向锁

将mark word内的偏向模式设为1,标志位设位01,则是偏向锁状态,锁竞争会锁升级位轻量级锁

具体的锁和并发可看JUC

final修饰的变量、java.lang.Number的部分子类(Long、Double…)
2. 绝对线程安全:不管什么环境,都是线程安全的
3. 相对线程安全:大部分线程安全集合类(Vector、hashMap….)等

锁优化

JDK5—->JDK1.6加入了很多的锁优化手段:适应性性自旋、锁消除、锁膨胀、轻量级锁、偏向锁

自旋锁

默认开启,在获取锁失败后,会自旋重试来获取锁

锁消除

对被检测导明确不会存在锁竞争的锁进行锁消除来提高运行效率,锁消除的根据还是逃逸分析技术(前面讲过了)

锁粗化

一串零碎的锁操作对同一个对象,加锁的范围扩展导整个操作序列的外部

轻量级锁

对象头(mark word)记录一些的对象的非数据信息,里面就包含了锁标志等,轻量级锁锁升级后会变成重量级锁

偏向锁

将mark word内的偏向模式设为1,标志位设位01,则是偏向锁状态,锁竞争会锁升级位轻量级锁

具体的锁和并发可看JUC



这篇关于深入理解java虚拟机(JVM)------一篇过的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程