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

《深入理解Java虚拟机》

2. Java内存区域与内存溢出异常

运行时数据区域

在这里插入图片描述

jdk1.8中…

程序计数器

当前线程执行的字节码的行号指示器。在虚拟机概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等都依赖这个计数器。

线程切换后为了能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储。这类内存为线程私有的内存。

Java虚拟机栈

Java虚拟机栈是线程私有的。描述Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链栈、方法出口等信息。

两种异常:如果请求的栈深度大于虚拟机允许的深度,抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,扩展时无法申请到足够的内存,抛出OutOfMemoryError异常。

本地方法栈

虚拟机使用到的Native方法。

Java堆

Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。唯一目的就是存放对象实例。

方法区

是各线程共享的内存区域。用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

方法区也被称为永久代。很多人都会分不清方法区和永久代的关系,为此我也查阅了文献。

方法区和永久代的关系

《Java虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。 方法区和永久代的关系很像Java中接口和类的关系,类实现了接口,而永久代就是HotSpot虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,永久代是HotSpot的概念,方法区是Java虚拟机规范中的定义,是一种规范,而永久代是一种实现,一个是标准一个是实现,其他的虚拟机实现并没有永久带这一说法。

运行时常量池

存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

运行时期也可能将新的常量放入池中,例如String类的intern()方法。

02276bcccbe181985ac50af2668940c9

对象创建过程

下面便是 Java 对象的创建过程

①类加载检查: 虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

②分配内存:类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式“指针碰撞”“空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定

内存分配的两种方式:(补充内容,需要掌握)

选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的

在这里插入图片描述

内存分配并发问题(补充内容,需要掌握)

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

  • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
  • TLAB: 为每一个线程预先在Eden区分配一块儿内存,JVM在给线程中的对象分配内存时,首先在TLAB分配,当对象大于TLAB中的剩余内存或TLAB的内存已用尽时,再采用上述的CAS进行内存分配

③初始化零值: 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

④设置对象头: 初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

⑤执行 init 方法: 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的访问定位有哪两种方式?

建立对象就是为了使用对象,我们的Java程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式有虚拟机实现而定,目前主流的访问方式有①使用句柄②直接指针两种:

  1. 句柄: 如果使用句柄的话,那么Java堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;

eea6f944dd5d522a079b43d409f620d7

  1. 直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而reference 中存储的直接就是对象的地址。

在这里插入图片描述
这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

如何判断一个常量是废弃常量?

运行时常量池主要回收的是废弃的常量。那么,我们如何判断一个常量是废弃常量呢?

假如在常量池中存在字符串 “abc”,如果当前没有任何String对象引用该字符串常量的话,就说明常量 “abc” 就是废弃常量,如果这时发生内存回收的话而且有必要的话,“abc” 就会被系统清理出常量池。

如何判断一个类是无用的类?

方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?

判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面3个条件才能算是 “无用的类”

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。

3. 垃圾收集器与内存分配策略

对象已死吗

垃圾收集器回收前第一件事就是确定这些对象哪些还“活着”,哪些已经“死去”

引用计数算法(Reference Counting)

给对象中添加一个引用计数器,每当一个地方引用它时,计数器就加1;当引用失效时,计数器就减1;任何时刻计数器为0的对象就是不可能再被使用的。

引用计数算法实现简单,效率高。但是很难解决对象之间相互循环依赖的问题。

可达性分析算法(Reachability Analysis)

通过一系列的称为“GC Roots”的对象作为起点,从这些起点开始往下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

在这里插入图片描述
可作为GC Roots的对象有以下几种

  • 虚拟机栈中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI引用的对象
引用分类

分为强引用、软引用、弱引用、虚引用4种

  • 强引用普遍存在,类似Object obj = new Object()。只要强引用存在,引用永远不会被回收掉。
  • 软引用描述还有用但非必须的对象。软引用关联的对象在系统内存溢出之前,将会把这些对象列进回收范围进行二次回收。如果还没有足够内存则抛出内存溢出。
  • 弱引用也是非必须对象。弱引用关联的对象只能生存到下次垃圾收集之前。
  • 虚引用。一个对象是否有虚引用对其生存时间没有影响。唯一目的就是能在对象被收集器回收时收到一个系统通知。

生存还是死亡

即使可达性分析算法中不可达,也不是非死不可。真正宣告对象死亡要经过两次标记:没有与GC Roots相连接的引用链,第一次标记。然后筛选判断是否必要执行finalize()方法。必须执行将会放在F-Queue队列中。

finalize()是对象逃脱死亡命运的最后一次机会,只需重新与引用链上的任何一个对象建立关联即可。

垃圾收集算法

标记-清除算法

标记出所有需要回收的对象,标记完成后统一回收。

两个不足:标记清除效率都不高;标记清除过后会产生大量不连续的内存碎片。

在这里插入图片描述

复制算法

将可用内存容量分为大小相等的两块。每次只使用其中一块,内存用完后将还存活的对象复制到另外一块上,然后把已使用过的内存一次清理。

实现简单、运行高效,但内存缩小为原来的一半。

在这里插入图片描述

现在商用虚拟机都是采用这种算法回收新生代,不需要按照1:1划分内存,而是将内存划分为一块较大的Eden空间和两小块Survivor,每次使用Eden和一块Survivor。回收时将Eden,Survivor还存活的对象复制到另一块Survivor上,清理掉使用过的空间。HotSpot虚拟机默认Eden和Survivor大小比例8:1。当Survivor空间不够用时,需要依赖其他内存(老年代)进行分配担保。

标记-整理算法

(根据老年代特点)

标记过程与‘标记-清除“算法一样,但不直接清除,而是让所有存活对象都向一端移动(对象间进行位置互换),然后清理端边界以外的内存。

在这里插入图片描述

分代收集算法

根据对象存活周期的不同将内存划分为几块。把Java堆分为新生代和老年代。新生代中每次垃圾收集时都会有大批对象死去,只有少量存活,使用“复制”算法。老年代对象存活率高、没有额外空间为他分配担保,使用“标记-清除”活着“标记-整理”算法

垃圾收集器

G1(Garbage-Firse)收集器

G1是一款面向服务端应用的垃圾收集器。具有以下特点

  • 并行与并发
  • 分代收集
  • 空间整合
  • 可预测的停顿

内存分配与回收策略

回收策略上面已经讲过,下面介绍内存分配

对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配,没有足够空间分配时,虚拟机将发起一次Minor GC。

大对象直接进入老年代

大对象是指:需要大量连续内存空间的Java对象,最典型的就是那种很长的字符串以及数组。

长期存活的对象将进入老年代

虚拟机给每个对象定义了一个对象年龄(Age)计数器。对象在Eden中出生经过第一次Minor GC后仍存活,并且能被Survivor容纳,将被移动到Survivor空间中,将对象年龄设为1。对象在Survivor区中每熬过一次Minor GC,年龄加1 。当年龄到一定程度(默认15岁),就将会晋升到老年代中。

对象年龄动态判定

如果Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

空间分配担保

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-70qCleXf-1589867472094)(pic/image-20200411171828390.png)]

Minor Gc和Full GC 有什么不同呢?

大多数情况下,对象在新生代中 eden 区分配。当 eden 区没有足够空间进行分配时,虚拟机将发起一次Minor GC。

  • 新生代GC(Minor GC):指发生新生代的的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快。
  • 老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC经常会伴随至少一次的Minor GC(并非绝对),Major GC的速度一般会比Minor GC的慢10倍以上。

虚拟机类加载机制

类加载的时机

类从加载到虚拟机内存中开始,到卸载出内存为止,它的每个生命周期包含:加载、验证、准备、解析、初始化、使用和卸载7个阶段。

在这里插入图片描述
在这里插入图片描述

类加载过程

类加载过程:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析

在这里插入图片描述

那加载这一步做了什么?

类加载过程的第一步,主要完成下面3件事情:

  1. 通过全类名获取定义此类的二进制字节流
  2. 将字节流所代表的静态存储结构转换为方法区的运行时数据结构
  3. 在内存中生成一个代表该类的 Class 对象,作为方法区这些数据的访问入口

虚拟机规范多上面这3点并不具体,因此是非常灵活的。比如:“通过全类名获取定义此类的二进制字节流” 并没有指明具体从哪里获取、怎样获取。比如:比较常见的就是从 ZIP 包中读取(日后出现的JAR、EAR、WAR格式的基础)、其他文件生成(典型应用就是JSP)等等。

一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步我们可以去完成还可以自定义类加载器去控制字节流的获取方式(重写一个类加载器的 loadClass() 方法)。数组类型不通过类加载器创建,它由 Java 虚拟机直接创建。

验证

验证是连接阶段的第一步,为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

类加载器

虚拟机的设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让程序自己决定去获取所需的类。实现这个动作的代码模块称为“类加载器”

双亲委派模型

JVM 中内置了三个重要的 ClassLoader,除了 BootstrapClassLoader 其他类加载器均由 Java 实现且全部继承自java.lang.ClassLoader

  1. BootstrapClassLoader(启动类加载器) :最顶层的加载类,由C++实现,负责加载 %JAVA_HOME%/lib目录下的jar包和类或者或被 -Xbootclasspath参数指定的路径中的所有类。
  2. ExtensionClassLoader(扩展类加载器) :主要负责加载目录 %JRE_HOME%/lib/ext 目录下的jar包和类,或被 java.ext.dirs 系统变量所指定的路径下的jar包。
  3. AppClassLoader(应用程序类加载器) :面向我们用户的加载器,负责加载当前应用classpath下的所有jar包和类。

双亲委派模型介绍

每一个类都有一个对应它的类加载器。系统中的 ClassLoder 在协同工作的时候会默认使用 双亲委派模型 。即在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。**加载的时候,首先会把该请求委派该父类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。当父类加载器无法处理时,才由自己来处理。**当父类加载器为null时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。

在这里插入图片描述

在这里插入图片描述
双亲委派模型实现源码分析

双亲委派模型的实现代码非常简单,逻辑非常清晰,都集中在 java.lang.ClassLoaderloadClass() 中,相关代码如下所示。

private final ClassLoader parent; 
protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 首先,检查请求的类是否已经被加载过
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {//父加载器不为空,调用父加载器loadClass()方法处理
                        c = parent.loadClass(name, false);
                    } else {//父加载器为空,使用启动类加载器 BootstrapClassLoader 加载
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                   //抛出异常说明父类加载器无法完成加载请求
                }

                if (c == null) {
                    long t1 = System.nanoTime();
                    //自己尝试加载
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

双亲委派模型带来了什么好处呢?

双亲委派模型保证了Java程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。如果不用没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现多个不同的 Object 类。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值