JVM的基础认知

一.JVM内存结构

首先来介绍一下JVM的内存结构,JVM中的内存结构主要有方法区、堆、虚拟机栈、本地方法栈和程序计数器。其中方法区和堆是线程共享资源的,而虚拟机栈、本地方法栈和程序计数器是线程独享资源的。

1.1堆

堆是Java虚拟机所管理的内存中最大的一块存储区域,主要存放使用new关键字创建的对象。所有对象实例以及数组都要在堆上分配,堆内存被所有线程共享。垃圾回收器就是根据GC算法,收集堆中对象所占用的内存空间。

堆分为年轻代(Young Generation)和老年代(Old Generation),其中年轻代默认占整个堆空间的1/3,老年代默认占整个堆空间的2/3;年轻代又分为伊甸园(Eden)和幸存区(Survivor区),伊甸区占新生代空间的4/5,幸存者区占1/5;幸存区又分为From Survivor(S0)空间和 To Survivor(S1)空间,他们两个平分幸存者区的空间。

通常新生成的对象一般都是放在新生代的伊甸园区。如果数据体积过大的对象会直接存入老年代,另外,年龄超过设定的阈值后,也会从新生代转移到老年代中进行存储。

如果在堆中没有内存来完成实例分配,并且堆也无法再扩展时,将会抛出OOM(OutOfMemoryError )异常。

我们可以通过设置Java堆的参数,优化减少FullGC:

-Xms:设置堆的初始空间大小。

-Xmx:设置堆的最大空间大小,一般不要大于物理内存的80%。

-XX:NewSize(-Xns):设置新生代初始空间大小。

-XX:MaxNewSize(-Xmn):设置新生代最大空间大小。

-XX:SurvivorRatio=8 :年轻代中Eden区与Survivor区的容量比例值,默认为8表示两个Survivor :eden=2:8,即一个Survivor占年轻代的1/10

1.2方法区

方法区与堆有很多共性:线程共享、内存不连续、可扩展、可垃圾回收,同样当无法再扩展时会抛出OutOfMemoryError异常。

正因为如此相像,Java虚拟机规范把方法区描述为堆的一个逻辑部分,但目前实际上是与Java堆分开的(Non-Heap)。

方法区它存储的是虚拟机中要加载的类的字节码文件、常量、静态变量等信息。

方法区的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是回收确实是有必要的。

以HotSpot 虚拟机来说,在 JDK1.8 之前,方法区也被称作为永久代。在JDK7之前的HotSpot虚拟机中,纳入字符串常量池的字符串被存储在永久代中,因此导致了一系列的性能问题和内存溢出错误。

JDK8之后就没有永久代这一说法变成叫做元空间(meta space)。元空间放置于本地的内存中,因此元空间的最大空间就是系统的内存空间了,从而减少出现像永久代的内存溢出错误了,也能降低出现泄漏的数据移到交换区这样的情况。用户可以为元空间设置一个可用空间最大值,防止异常占用过多物理内存。

1.3虚拟机栈

Java 虚拟机的每一条线程都有自己私有的 Java 虚拟机栈,这个 Java 虚拟机栈跟线程同时创建,所以它跟线程有相同的生命周期。

Java 虚拟机栈描述的是 Java 方法执行的内存模型:每一个方法在执行的同时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息,每一个方法从调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中的入栈到出栈的过程。

局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。包括8种基本数据类型、对象引用(reference类型)和returnAddress类型(指向一条字节码指令的地址)。

操作数栈(Operand Stack)也称作操作栈,是一个后入先出栈(LIFO)。随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者。

动态链接:Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态链接(Dynamic Linking)。

方法出口:无论方法是否正常完成,都需要返回到方法被调用的位置,程序才能继续进行。

1.4本地方法栈

和虚拟栈相似,只不过它服务于Native方法,线程私有。当 Java 虚拟机使用其他语言(例如 C 语言)来实现指令集解释器时,会使用到本地方法栈。

1.5程序计数器

程序计数器可以看作是当前线程所执行字节码的行号指示器,指向下一个将要执行的指令代码,由执行引擎来读取下一条指令。更确切的说,一个线程的执行,是通过字节码解释器改变当前线程的计数器的值,来获取下一条需要执行的字节码指令,从而确保线程的正确执行。

为了确保线程切换后(上下文切换)能恢复到正确的执行位置,每个线程都有一个独立的程序计数器,各个线程的计数器互不影响,独立存储。也就是说程序计数器是线程私有的内存。

如果线程执行 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果执行的是 Native 方法,计数器值为Undefined。

程序计数器是由CPU的寄存器实现的,不会发生内存溢出问题。

二.垃圾回收器

2.1垃圾判断算法

在 JVM 进行垃圾回收之前,首先就是判断哪些对象是垃圾,也就是说,要判断哪些对象是可以被销毁的,其占有的空间是可以被回收的。根据 JVM 的架构划分,我们知道, 在 Java 世界中,几乎所有的对象实例都在堆中存放,所以垃圾回收也主要是针对堆来进行的。

在 JVM 的眼中,垃圾就是指那些在堆中存在的,已经“死亡”的对象。而对于“死亡”的定义,我们可以简单的将其理解为“不可能再被任何途径使用的对象”。那怎样才能确定一个对象是存活还是死亡呢?这就涉及到了垃圾判断算法,其主要包括引用计数法和可达性分析法。

2.1.1引用计数法

在这种算法中,假设堆中每个对象都有一个引用计数器。当一个对象被创建并且初始化赋值后,该对象的计数器的值就设置为 1,每当有一个地方引用它时,计数器的值就加 1,例如将对象 b 赋值给对象 a,那么 b 被引用,则将 b 引用对象的计数器累加 1。

反之,当引用失效时,例如一个对象的某个引用超过了生命周期(出作用域后)或者被设置为一个新值时,则之前被引用的对象的计数器的值就减 1。而那些引用计数为 0 的对象,就可以称之为垃圾,可以被收集。特别地,当一个对象被当做垃圾收集时,它引用的任何对象的计数器的值都减 1。

  • 优点:引用计数法实现起来比较简单,对程序不被长时间打断的实时环境比较有利。

  • 缺点:需要额外的空间来存储计数器,难以检测出对象之间的循环引用。

2.1.2可达性分析法

可达性分析法也被称之为根搜索法,可达性是指,如果一个对象会被至少一个在程序中的变量通过直接或间接的方式被其他可达的对象引用,则称该对象就是可达的。更准确的说,一个对象只有满足下述两个条件之一,就会被判断为可达的:

  • 对象是属于根集中的对象

  • 对象被一个可达的对象引用

在这里,我们引出了一个专有名词,即根集,其是指正在执行的 Java 程序可以访问的引用变量(注意,不是对象)的集合,程序可以使用引用变量访问对象的属性和调用对象的方法。在 JVM 中,会将以下对象标记为根集中的对象,具体包括:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象

  • 方法区中的常量引用的对

  • 方法区中的类静态属性引用的对象

  • 本地方法栈中(Native 方法)的引用对象

  • 活跃线程(已启动且未停止的 Java 线程)

根集中的对象称之为GC Roots,也就是根对象。可达性分析法的基本思路是:将一系列的根对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,如果一个对象到根对象没有任何引用链相连,那么这个对象就不是可达的,也称之为不可达对象。被识别为不可达的对象,GC就可以进行回收删除。

当然,可达性分析法有优点也有缺点:

  • 优点:可以解决循环引用的问题,不需要占用额外的空间

  • 缺点:多线程场景下,其他线程可能会更新已经访问过的对象的引用

2.2垃圾回收算法

2.2.1标记-清除算法

标记/清除算法的基本思想就跟它的名字一样,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

标记阶段:

标记的过程其实就是前面介绍的可达性分析算法的过程,遍历所有的 GC Roots 对象,对从 GCRoots 对象可达的对象都打上一个标识,一般是在对象的 header 中,将其记录为可达对象。

清除阶段:

清除的过程是对堆内存进行遍历,如果发现某个对象没有被标记为可达对象(通过读取对象header 信息),则将其回收。

优点:

不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效。

缺点:

标记和清除过程的效率都不高,这种方法需要使用一个空闲列表来记录所有的空闲区域以及大小,对空闲列表的管理会增加分配对象时的工作量;标记清除后会产生大量不连续的内存碎片,虽然空闲区域的大小是足够的,但却可能没有一个单一区域能够满足这次分配所需的大小,因此本次分配还是会失败,不得不触发另一次垃圾收集动作。

2.2.2标记-整理算法

标记-整理(Compacting Collector)算法标记的过程与“标记-清除”算法中的标记过程一样,但对标记后出的垃圾对象的处理情况有所不同,它不是直接对可回收对象进行清理,而是让所有的对象都向一端移动,然后直接清理掉端边界以外的内存。

优点:

经过整理之后,新对象的分配只需要通过指针碰撞便能完成,比较简单;使用这种方法,空闲区域的位置是始终可知的,也不会再有碎片的问题了。

缺点:

GC 暂停的时间会增长,因为你需要将所有的对象都拷贝到一个新的地方,还得更新它们的引用地址。

2.2.3复制算法

复制(Copying Collector)算法的提出是为了克服句柄的开销和解决堆碎片的垃圾回收。它将内存按容量分为大小相等的两块,每次只使用其中的一块(对象面),当这一块的内存用完了,就将还存活着的对象复制到另外一块内存上面(空闲面),然后再把已使用过的内存空间一次清理掉。

复制算法比较适合于新生代(短生存期的对象),在老年代(长生存期的对象)中,对象存活率比较高,如果执行较多的复制操作,效率将会变低,所以老年代一般会选用其他算法,如“标记-整理”算法。一种典型的基于复制算法的垃圾回收是stop-and-copy算法,它将堆分成对象区和空闲区,在对象区与空闲区的切换过程中,程序暂停执行。

优点:

标记阶段和复制阶段可以同时进行;每次只对一块内存进行回收,运行高效;只需移动栈顶指针,按顺序分配内存即可,实现简单;内存回收时不用考虑内存碎片的出现。

缺点:

需要一块能容纳下所有存活对象的额外的内存空间。因此,可一次性分配的最大内存缩小了一半。

2.2.4分代收集算法

分代收集(Generational Collector)算法的将堆内存划分为新生代和老年代。新生代又被进一步划分为 Eden 和 Survivor 区,其中 Survivor 由 FromSpace(S0)和 ToSpace(S1)组成。所有通过new创建的对象的内存都在堆中分配。分代收集,是基于一个思想:不同的对象的生命周期是不一样的。因此,可以将不同生命周期的对象分代,不同的分代采取不同的回收算法进行垃圾回收,以便提高回收效率。

在分代收集算法中,对象的存储具有以下特点:

  1. 对象优先在 Eden 区分配。

  2. 大对象直接进入老年代。

  3. 长期存活的对象将进入老年代,默认为 15 岁。

JVM分代年龄最大为什么是15?

对于晋升老年代的分代年龄阈值,我们可以通过-XX:MaxTenuringThreshold参数进行控制。在这里,不知道大家有没有对这个默认的 15 岁分代年龄产生过疑惑,为什么不是 16 或者 17 呢?实际上,HotSpot 虚拟机的对象头其中一部分用于存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32bit 和 64bit,官方称它为Mark word。

例如,在 32 位的 HotSpot 虚拟机中,如果对象处于未被锁定的状态下,那么Mark Word的 32bit 空间中 25bit 用于存储对象哈希码,4bit 用于存储对象分代年龄(32位和64位的Hot Spot的分代年龄都占4bit),2bit 用于存储锁标志位,1bit 固定为 0,其中对象的分代年龄占 4 位,也就是从0000到1111,而其值最大为 15,所以分代年龄也就不可能超过 15 这个数值了。

分代收集算法的具体流程是:

当新对象生成,Eden 空间申请失败(因为空间不足等),则会发起一次 GC(Scavenge GC)。回收时先将 Eden 区存活对象复制到一个 Survivor0 区,然后清空 Eden 区,当这个 Survivor0 区也存放满了时,则将 Eden 区和 Survivor0 区存活对象复制到另一个 Survivor1 区,然后清空 Eden 和这个 Survivor0 区,此时 Survivor0 区是空的,然后将 Survivor0 区和 Survivor1 区交换,即保持 Survivor1 区为空, 如此往复。当 Survivor1 区不足以存放 Eden 和 Survivor0 的存活对象时,就将存活对象直接存放到老年代。当对象在 Survivor 区躲过一次 GC 的话,其对象年龄便会加 1,默认情况下,如果对象年龄达到 15 岁,就会移动到老年代中。若是老年代也满了就会触发一次 Full GC,也就是新生代、老年代都进行回收。

三.类加载器

类加载器的作用就是将磁盘上的class文件加载到虚拟机内存中。

3.1类加载器的分类

3.1.1引导类加载器 BootstrapClassLoader

引导类加载器属于JVM的一部分,由C++代码实现。

引导类加载器负责加载<JAVA_HOME\>\jre\lib路径下的核心类库,由于安全考虑只加载 包名 java、javax、sun开头的类。

3.1.2 扩展类加载器 ExtClassLoader

全类名:sum.misc.Launch$ExtClassLoader,Java语言实现。

扩展类加载器的父加载器是Bootstrap启动类加载器 (注:不是继承关系)

扩展类加载器负责加载<JAVA_HOME>\jre\lib\ext目录下的类库。

3.1.3 系统类加载器 AppClassLoader

全类名: sun.misc.Launcher$AppClassLoader

系统类加载器的父加载器是ExtClassLoader扩展类加载器(注: 不是继承关系)。

系统类加载器负责加载 classpath环境变量所指定的类库,是用户自定义类的默认类加载器。

3.1.4自定义类加载器

自定义类加载器是为了加载在jvm三个加载器负责的目录范围之外的类

3.2类加载的过程

加载:将字节码文件通过IO流读取到JVM的方法区,并同时在堆中生成Class对像。

验证:校验字节码文件的正确性。

准备:为类的静态变量分配内存,并初始化为默认值;对于final static修饰的变量,在编译时就已经分配好内存了。

解析:将类中的符号引用转换为直接引用。

初始化:对类的静态变量初始化为指定的值,执行静态代码。

3.3双亲委派机制

双亲委派机制:

如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个类请求任务委托给父类加载器去完成,依次递归,如果父加载器可以完成类加载任务,就成功返回。只有父类加载器无法完成此加载任务时,才自己去加载。

优势:

  • 避免了类的重复加载,确保一个类的全局唯一性

    Java类随着它的类加载器一起具备了一种带有优先级的层级关系,通过这种层级关系可以避免类的重复加载,当父加载器已经加载了该类时,就没有必要子加载器再加载一次。

  • 保护程序安全,防止核心API被随意篡改。

劣势

  • 顶层的ClassLoader无法访问到底层的ClassLoader所加载的类

在Tomcat中,类加载器所采用的加载机制和传统的双亲委派机制有一定的区别,当缺省的类加载器接受到一个类的加载任务时,首先会由它自行加载,当他加载失败时,才会将类的加载任务委派给它的超类加载器去执行,这同时也是Servlet规范推荐的一种做法。

  • 6
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值