《深入理解Java虚拟机》学习笔记

本文深入探讨Java虚拟机(JVM)的运作原理,包括虚拟机的概念、Java内存区域的划分,如本地方法栈、虚拟机栈、堆、程序计数器、方法区等。详细讲解了对象的创建、访问及内存异常,以及垃圾收集和内存分配策略。同时,介绍了类文件结构、虚拟机的加载机制、执行过程、双亲委派模型和线程安全概念。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

400页的电子书终于研读一遍。其中的原理知识让我受益匪浅。
对于不少开发人员来说,c/c++是接触的第一种语言,在内存管理方面,开发人员要负责每一个对象的全生命周期。而开发Java项目,就简单一些,内存管理交给虚拟机去负责。咱一起看看虚拟机是如何做到的。

什么是虚拟机

直观的解释:
虚拟机是一种抽象化的计算机,通过在实际的计算机上仿真模拟各种计算机功能来实现的。Java虚拟机有自己完善的硬体架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。JVM屏蔽了与具体操作系统平台相关的信息,使得Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。

Java内存区域

运行时数据区域

1.本地方法栈 :调用其他语言,为虚拟机使用的native方法服务。

2.虚拟机栈 :方法的执行,跳转。每个方法在执行同时都会创建一个栈帧用于存储局部变量表,操作数栈,动态链接,方法出口等信息。局部变量存储了基本数据类型,对象引用等。只有栈顶的栈帧是有效的。
在这里插入图片描述
3.堆:内存最大;所有线程共享的一段内存。可以处于物理上不连续,逻辑上连续;是垃圾收集器管理的主要区域;

4.程序计数器 :标记程序执行位置。内存占用较小;属于线程私有的;每个线程有独立的程序计数器;各线程之间的程序计数器直接互不影响;此内存区域是唯一一个在Java虚拟机规范中没有规定任何outofmemoryerror情况;

5.方法区:各个线程共享的内存区域,用于存储已被虚拟机加载的类信息,常量,静态变量,当方法区无法满足内存分配需求时,将抛出outofmemoryerror异常。不需要连续的内存空间;

6.运行时常量池:属于方法区的一部分。class文件中除了有类的模板,字段,方法,接口等描述信息外,还有常量池,用于存放编译期生成的各种字面量和符号引用,将存放于方法区的运行时常量池。
在这里插入图片描述

对象的创建

1.为新生对象分配内存
2.内存空间初始化为零值
3.虚拟机对对象进行必要的设置
然后执行方法

对象的访问

引用访问对象:使用句柄和直接引用

内存异常

Java堆溢出:OutOfMemoryError
内存泄漏 memory leak; 内存溢出 memory overflow
内存泄漏:垃圾回收器无法自动回收内存,需要定位内存泄漏代码位置。当一个对象已经不被应用程序使用以后,他所占用的内存没有得到及时的释放,导致内存使用量不断增加。
内存溢出:需要检查某些对象生命周期过长的情况;调大堆参数
堆最小值:Xms;堆最大值 Xmx

Java栈溢出:stackoverflowerror
单线程情况下:
栈内存容量太小或者定义了大量的本地变量;
多线程情况下:栈内存容量太大,导致无法创建更多线程。
栈容量:Xss

xx:PermSize 和xx:MaxPermSize 限制方法区大小
-XX:MaxDirectMemorySize 限制直接内存大小

垃圾收集和内存分配策略

判断对象是否已死

1.引用计数法:有地方引用时,计数器加1;引用失效,计数器减1,计数器为0表示对象不再使用。
优点:实现简单,效率高;
缺点:很难解决对象之间相互循环引用的问题。
2.可达性分析算法

真正宣告一个对象死亡,至少经过两次标记过程。

垃圾收集算法

标记-清除算法,首先标记出所有需要回收的对象,然后进行统一的回收,不足之处有两个:效率低、碎片多。

复制算法,将可用内存划分成大小相等的两块,每次只使用一块,当一块用完了,就将还存活的对象复制到另外一块上,然后把已使用的内存空间清理掉。不足之处是将内存缩小到一半,利用率不高。

标记-整理算法,与标记-清除类似,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的区域

分代收集算法,分代收集是目前jvm普遍采用的算法,即新生代采用复制算法,因为有大量新生对象死去,只有少量存活;老年代采用标记-整理,因为老年代中对象存活率高,没有额外的空间对它进行担保。

内存分配策略

Java堆分成新生代和老年代。
年轻代(Young Gen)年轻代主要存放新创建的对象,内存大小相对会比较小,垃圾回收会比较频繁。年轻代分成1个Eden Space和2个Suvivor Space(命名为A和B)。当对象在堆创建时,将进入年轻代的Eden Space。垃圾回收器进行垃圾回收时,扫描Eden Space和A Suvivor Space,如果对象仍然存活,则复制到B Suvivor Space,如果B Suvivor Space已经满,则复制到Old Gen。同时,在扫描Suvivor Space时,如果对象已经经过了几次的扫描仍然存活,JVM认为其为一个持久化对象,则将其移到Old Gen。扫描完毕后,JVM将Eden Space和A Suvivor Space清空,然后交换A和B的角色 。

我们可以看到:Young Gen垃圾回收时,采用将存活对象复制到到空的Suvivor Space的方式来确保尽量不存在内存碎片,采用空间换时间的方式来加速内存中不再被持有的对象尽快能够得到回收。

年老代(Tenured Gen)年老代主要存放JVM认为生命周期比较长的对象(经过几次的Young Gen的垃圾回收后仍然存在),内存大小相对会比较大,垃圾回收也相对没有那么频繁(譬如可能几个小时一次)。年老代主要采用压缩的方式来避免内存碎片(将存活对象移动到内存片的一边,也就是内存整理)。当然,有些垃圾回收器(譬如CMS垃圾回收器)出于效率的原因,可能会不进行压缩。大对象直接进入老年代。

持久代(Perm Gen):持久代主要存放类定义、字节码和常量等很少会变更的信息。不属于堆区。

类文件结构

各种不同平台的虚拟机与所有平台都统一使用的程序存储格式-字节码是构成平台无关性的基石。
Java虚拟机也做到了语言无关性。实现语言无关性的基础仍然是虚拟机和字节码存储格式,Java虚拟机不和任何语言绑定,它只与Class文件的二进制文件格式相关联,理论上讲,任一门功能性语言都可以表示为一个能被Java虚拟机所接受的有效的Class文件。

Class文件结构包括以下内容:
魔数:文件的前四个字节,确定这个文件能否被Java虚拟机接受
版本号:接下来的四个字节,Class文件的版本号
常量池:Class文件的资源仓库,存放字面量(类似常量)和符号引用(

  1. 类和接口的全限定名
  2. 字段的名称和描述符
  3. 方法的名称和描述符

    访问标志:用于识别一些类或者接口层次的访问信息,是类还是接口?是否为public?
    索引集合:包括类索引、父类索引、接口索引
    字段表集合:描述接口或类中声明的变量,但不包括方法内部的局部变量
    方法表集合:代码编译后存在方法表中的属性集合“Code”属性
    属性表集合:字段表、方法表都可以携带自己的属性表,以用于描述某些场景专用信息
    Java虚拟机采用面向操作数栈而不是寄存器的架构。

虚拟机加载

虚拟机的类加载机制:虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验/转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。因此执行的Java程序实际是执行的JVM进程,而class文件是作为加载的数据。

类加载的整个生命周期:加载,验证,准备,解析,初始化,使用和卸载
在这里插入图片描述

  • 加载
    加载阶段虚拟机需要完成以下3件事:
    (1) 通过一个类的全限定名来获取此类的二进制字节流
    (2) 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
    (3) 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

  • 验证
    目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。符号引用验证,字节码验证,源数据验证,文件格式验证

  • 准备
    正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区进行分配,注意是类变量(static修饰),不是实例变量。这里的初始值通常情况下是数据类型的零值。而不是用户设值。

  • 解析
    虚拟机将常量池内的符号引用替换为直接引用的过程,包括类或接口的解析、字段解析、类方法解析、接口方法解析。

  • 初始化
    执行Java代码,初始化类和其他资源

类加载器

类加载器用于实现类的加载动作,对于任意一个类,都需要由加载它的类加载器和这个类本省一同确立其在Java虚拟机中的唯一性,每个类加载器都有一个独立的类名称空间,比较两个类是否相等,只有在这两个类是同一个类加载器加载的前提下才有意义。,例如Class对象的equals()、isInstance()。
从Java虚拟机的角度讲,只存在两种不同的类加载器。启动类加载器和所有其他的类加载器。
从Java开发人员的角度看,类加载器可划分为3种:

启动类加载器,负责加载存放在<JAVA_HOME>\lib下面的类库
扩展类加载器,负责加载存放在<JAVA_HOME>\lib\ext下面的类库
应用程序类加载器,负责加载用户路径上的类库

双亲委派模型的工作过程是:如果一个类加载器收到了类加载请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去加载,每一层次的类加载器都是如此,因此所有的加载请求最终都应传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。

虚拟机字节码执行

分派

静态分派:编译器是依赖静态类型来判断和执行方法的,静态分派的典型应用是方法重载。

static class Man extends Human{
}

Human man = new Man();
sr.sayHello((Man) man);

Human是静态类型,Man是实际类型

动态分派:重要体现:方法的重写 ,动态分派会调用invokevirtual指令。根据实际类型确定方法。

线程安全

内存共享

当多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替运行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获取正确的结果,那这个对象是线程安全的。

也就是说在多线程中,执行结果是符合预期结果则是线程安全的。
出现线程安全的问题一般是因为主内存和工作内存数据不一致性和重排序导致的,知道了问题原因,也就找到了解决问题的突破口。
数据不一致就需要进行通信。java内存模型是共享内存的并发模型,线程之间主要通过读-写共享变量来完成隐式通信。在java程序中所有实例域,静态域和数组元素都是放在堆内存中。所有线程共享这些数据。
为了平衡CPU和主存的速度的差距,每个CPU都会有缓存。因此,共享变量会先放在主存中,每个线程都有属于自己的工作内存,并且会把位于主存中的共享变量拷贝到自己的工作内存,之后的读写操作均使用位于工作内存的变量副本,并在某个时刻将工作内存的变量副本写回到主存中去。JMM就从抽象层次定义了这种方式,并且JMM决定了一个线程对共享变量的写入何时对其他线程是可见的。如果线程A没有及时的把变量副本更新到主内存,线程B就会出现脏读
可以通过同步机制(控制不同线程间操作发生的相对顺序)来解决或者通过volatile关键字使得每次volatile变量都能够强制刷新到主存,从而对每个线程都是可见的。

在这里插入图片描述

重排序

一个好的内存模型实际上会放松对处理器和编译器规则的束缚,也就是说软件技术和硬件技术都为同一个目标而进行奋斗:在不改变程序执行结果的前提下,尽可能提高并行度。JMM对底层尽量减少约束,使其能够发挥自身优势。因此,在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。一般重排序可以分为如下三种:
在这里插入图片描述
编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。
编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖性关系的两个操作的执行顺序。

happens-before规则

1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。

线程安全内容参考: https://www.jianshu.com/p/d52fea0d6ba5

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值