深入理解Java虚拟机(全)

  • 垃圾回收,类加载,线程安全问的比较多
  • 2,3,6,7,12,13

第二章 Java内存区域与内存溢出异常

2.2 运行时数据区域

image-20210424102219868

3个区域线程私有(不需要垃圾回收,因为它们随着线程结束而自动销毁),2个区域所有线程共享(需要垃圾收集回收)

  1. 程序计数器(Programmer Counter Register):一块很小的内存,可以看做当前线程所执行的字节码的行号计数器。

    • 线程隔离的数据区(线程私有)
    • 为了多线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各线程之间计数器互不影响,独立存储
    • 如果线程正在执行一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址
    • 如果执行的是一个本地(Native)方法,这个计数器的值则为空(Undefined)
    • 唯一一个没有规定任何OutOfMemory情况的区域
  2. Java虚拟机栈(Java Virtual Machine Stack)

    • 我们常说的 ”堆“ 和 “栈” 中的 栈

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

      1. 局部变量表:存放编译期间可知的各种Java虚拟机基本数据类型(8种)、对象引用和returnAddress类型(指向了一条字节码指令的地址)
        • 这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,64位的long和double占两个变量槽,其余占一个变量槽
        • 局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。(大小指槽数,有具体虚拟机决定)
    • 线程私有,生命周期与线程一样

    • 两类异常情况

      • StackOverflowError异常:线程请求的栈深度大于虚拟机所允许的深度
      • OutOfMemoryError异常:Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存
        • HotSpot虚拟机的栈容量不可动态扩展。只要申请栈空间成功了就不会由于虚拟机栈无法扩展而导致OOM异常
  3. 本地方法栈(Native Method Stacks)

    • 与虚拟机作用相似,区别是:
      • 虚拟机栈为虚拟机执行Java方法(也就是字节码)服务
      • 而本地方法栈则是为虚拟使用到的本地方法服务
    • 具体虚拟机可自由实现它
      • HotSpot直接将本地方法栈和虚拟机栈合二为一
    • 与虚拟机栈一样,本地方法栈会在栈深度溢出和栈扩展失败时分别抛出StackOverflowError异常、OutOfMemoryError异常
  4. Java堆(Java Heap)

    • 虚拟机所管理的内存最大的一块
    • 所有线程共享,在虚拟机启动时创建
    • 唯一目的:存放对象实例(对象实例加数组都应当在堆上分配)
    • Java堆是垃圾收集器管理的内存区域
    • 在Java中,堆内存被分为两个不同的区域:新生代和老年代
      • 新生代又被划分为三个区域:Eden,FromSurvivor,ToSurvivor
      • 这样划分的目的是是JVM更好地管理堆内存中的对象,包括内存的分配以及回收。
    • 从内存分配的角度看,所有线程共享的Java堆可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB),以提升对象分配效率
    • Java堆可以内存上不连续,但逻辑上应该视为连续的(一般数组要求连续的内存空间)
    • Java堆可以实现成固定大小的,也可以是可扩展的(主流,通过参数-Xmx和Xms设定)
    • 如果Java堆没有内存完成实例分配,且堆也无法再扩展时,会出现OutOfMemoryError异常
  5. 方法区(Method Area)

    • 各个线程共享

    • 用于存储已被虚拟机加载的类信息常量、静态变量、即时编译器编译后的代码等数据

    • 不需要连续内存空间

    • 可选择固定大小或者可扩展

    • 可选择不实现垃圾收集,垃圾收集行为在这个区域出现得比较少

    • 内存回收目标主要针对:常量池回收(废弃常量),对类型的卸载(不再使用的类型)

    • 当方法区无法满足新的内存分配需求时,会出现OutOfMemoryError异常

      运行时常量池(Runtime Constant Pool)

    • 方法区的一部分

    • (Java文件被编译成class文件)Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表

    • 常量池表用于存放编译期生成的各种字面量符号引用,这部分内容将在类加载后存放到**方法区的运行时常量池(**每个类都有一个运行时常量池)中!!!!!!

    • 动态性:Java语言不要求常量只有编译期才能产生。即并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中(如String类的intern()方法)

    • 在这里插入图片描述

直接内存(DirectMemory)

不属于虚拟机运行时数据区的一部分,但也会被频繁使用


2.3 虚拟机对象(以Java堆中的对象为例)

1.对象的创建

  1. 类加载检查:遇到一个new指令时,首先检查这个指令参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有则执行相应的类加载过程
  2. 为新生对象分配内存
    • 指针碰撞(Bump The Pointer):使用带压缩整理过程的收集器时
    • 空闲列表(Free List):维护一个列表,记录内存是否可用。使用CMS这种基于清除整理过程的收集器时
  3. 将分配到的内存空间(但不包括对象头)都初始化为零值。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就可以直接使用,使程序能直接访问到这些字段的数据类型所对应的零值
  4. 对对象进行必要的设置:例如该对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存在对象的对象头中。

2. 对象的内存布局

在HotSpot中,对象在堆内存中的存储布局划分为:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)

  • 对象头:
    • 存储对象自身的运行时数据(如hashCode,GC分代年龄,锁状态标志,线程持有的锁等)-----MarkWord
    • 类型指针:对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例(如果对象是数据,还必须在对象头中存储数组大小)
      • Java虚拟机可以通过普通Java对象的元数据信息确定Java对象大小,但是如果数组的长度不确定的话,就无法通过元数据中的信息推断出述责的大小
  • 实例数据:对象真正存储的有效信息
  • **对齐填充:**不是必然存在的,仅仅起着占位符的作用。HotSpot虚拟机中要求所有对象的大小必须是8字节的整数倍。对象头部分已经是8字节的整数倍了,如果对象实例数据部分没有对齐的话,就需要通过对齐方式填充来补全。

3.对象的访问定位

Java程序通过栈上的reference数据来操作堆上的具体对象。

对象访问方式由具体的虚拟机实现而定

访问方式:

  • 使用句柄:reference中存储的是对象的句柄地址

    好处:reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普通的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改

  • 直接指针:reference中存储的是对象地址

    好处:速度快,节省一次指针定位的时间开销(HotSpot中主要使用这种方式进行对象访问)

image-20210424133157889 image-20210424133218285

2.4 OutOfMemoryError

1. Java堆溢出

dump:转储。动态(易失)的数据,保存为静态的数据(持久数据)

1、为什么要dump(dump的目的)?

因为程序在计算机中运行时,在内存、CPU、I/O等设备上的数据都是动态的(或者说是易失的),也就是说数据使用完或者发生异常就会丢掉。如果我想得到某些时刻的数据(有可能是调试程序Bug或者收集某些信息),就要把他转储(dump)为静态(如文件)的形式。否则,这些数据你永远都拿不到。

解决方法:通过内存影响分析工具堆Dump出来的堆转储快照进行分析

  1. 确认内存中导致OOM的对象是否是必要的(分清楚是出现了MemoryLeak还是MemoryOverflow)
  2. 如果是内存泄漏,则通过工具查看泄漏对象到GC Roots的引用链,找到磁轭楼对象是通过怎样的引用路径、与哪些GC Roots相关联,才导致垃圾收集器无法回收它们
  3. 如果不是内存泄漏(内存中的对象都是必须存活的),则应当检查Java虚拟机的堆参数(-Xmx与-Xms)设置,与及其的内存对比,看看是否还有向上调整的空间。再从代码上检查是否某些对象的生命周期过长、持有状态时间过长、存储结构设计不合理等情况,应尽量减少程序运行期间的内存消耗

2. 虚拟机栈和本地方法栈溢出

HotSpot虚拟机中不区分虚拟机栈和本地方法栈,栈容量大小仅由-Xxs参数设定

两种异常:

  1. 如果线程请求的栈深度大于虚拟机所允许的栈深度,将抛出StackOverflowError异常
  2. 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常
    • HotSpot虚拟机不支持扩展,只会在线程创建申请内存时就因无法获得足够内存而出现OutOfMemoryError,否则在线程运行时时不会因为扩展而导致内存溢出的,只会因为栈容量无法容纳新的栈帧而导致StackOverflowError。
    • 无论是栈帧太大 或者 虚拟机栈容量太小,当心的栈帧内存无法分配的时候,HotSpot虚拟机抛出的都是StackOverflowError异常

操作系统分配给每一个进程的内存时有限制的,譬如32位Windows的单个进程最大内存是2GB。HotSpot虚拟机提供了参数可以控制Java堆和方法区这两个部分的内存的最大值,那剩下的内存限制即为2GB(操作系统限制)减去最大堆容量,再减去最大方法区容量,由于程序计数器消耗内存很小,可以忽略掉,如果再把直接内存虚拟机进程本身消耗的内存也去掉的话,剩下的内存就由虚拟机栈本地方法栈来分配。

所以每个线程分配到的栈内存越大,可以建立的线程数量自然就越少,建立线程时就越容易将剩下的内存耗尽。

3.方法区和运行时常量池溢出

image-20210424151059057
  • JDK6之前,HotSpot虚拟机的常量池都是分配在永久代中,可通过-XX:PermSize和-XX:MaxPermSize限制永久代的大小,即可间接限制其中常量池的大小
  • JDK7起。原本存放在永久代的字符串常量池被移至Java堆中(-Xmx参数限制最大堆大小)
  • JDK8使用 “元空间” 代替 “永久代”
image-20210424152644043

java在加载sun.misc.Version这个类的时候进入常量池中。

4. 本机直接内存溢出

image-20210424154335043


第三章 垃圾收集器与内存分配策略

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来

3.1 概述

垃圾收集器(Garbage Collection, GC)

程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每个栈帧中分配多少内存基本上是在类结构确定下来是就已知的。因此这几个区域的内存分配和回收都具备确定性,不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就随着回收了。

Java堆和方法区则有着显著的不确定性

image-20210424162006308

3.2 对象已死?

堆中存放这几乎所有的实例对象,垃圾收集器在对堆进行回收之前,首先要确定哪些还“存活”着,哪些已经“死去”。

(死去: 即不可能再被任何途径使用到的对象)

1. 引用计数算法(Reference Counting)

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

优点:原理简单,判定效率也很高

缺点:这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作。譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。(在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存)

testGC()方法:对象objA和objB都有字段instance,赋值令 objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已 经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也 就无法回收它们。

2. 可达性分析算法

当前主流的商用程序语言(Java、C#,上溯至前面提到的古老的Lisp)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的

基本思路:就是通过一系列称为 “GC Roots” 的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的

image-20210424163708757

在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。

  • 方法区类静态属性引用的对象,譬如Java类的引用类型静态变量。

  • 方法区常量引用的对象,譬如字符串常量池(String Table)里的引用。

  • 本地方法栈JNI(Java Native Interface,即通常所说的Native方法引用的对象

  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。

  • 所有被同步锁(synchronized关键字)持有的对象

  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。

内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不 可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去,才能保证可达性分析的正确性。

3. 引用

在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为:

  • 强引用(Strongly Reference)

    • 传统的引用定义,指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。
  • 软引用(Soft Reference)

    • 一些还有用,但非必须的对象。
    • 只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存, 才会抛出内存溢出异常。
    • 在JDK 1.2版之后提供了SoftReference类来实现软引用。
  • 弱引用(Weak Reference)

    • 那些非必须对象,但是它的强度比软引用更弱一些

    • 被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够都会回收掉只被弱引用关联的对象

    • WeakReference类来实现弱引用

  • 虚引用(Phantom Reference)

    • 为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。

    • 一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知

    • PhantomReference类来实现虚引用。

Phantom [fæntəm] n.

这4种引用强 度依次逐渐减弱。

4. 生存还是死亡?

即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段

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

  1. 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记

  2. 随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法

    • 假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。
    • 如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。

    对象可以在被GC时自我拯救

    这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次

    如果对 象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集 合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。

5. 回收方法区

方法区的垃圾收集主要回收两部分内容:

  • 废弃的常量
  • 不再使用的类型。

回收废弃常量与回收 Java堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。

**判定一个类型是否属于“不再被使用的类”**需要同时满足下面三个条件:

  1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。

  2. 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。

  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

3.3 垃圾收集算法

1. 分代收集理论

当前商业虚拟机的垃圾收集器,大多数都遵循了**“分代收集”(Generational Collection)的理论**进行设计

  1. 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  2. 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消 亡。
  3. 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。

**设计原则:**收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区 域之中存储。

在Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域

“Minor GC”“Major GC”“Full GC”

  1. 标记-复制算法

  2. 标记-清除算法

  3. 标记-整理算法

新生代 & 老年代

新生代:标记-复制算法

老年代:标记-清除 或者 标记-整理算法

对象不是孤立的,对象之间会存在跨代引用。

存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。

(1)解决跨代引用:

我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。虽然这种方 法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

image-20210425160035184

通常能单独发生收集行为的只是新生代,所以这里“反过来”的情况只是理论上允许,实际上除了CMS收集器,其他都不存在只针对老年代的收集。

2. 标记-清除算法

  • 标记
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值