一文明白JVM-万字长文,遇人随便问

Posted 双斜杠少年

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一文明白JVM-万字长文,遇人随便问相关的知识,希望对你有一定的参考价值。

本文目录

  1. 类加载
    1.1 类加步骤
    1.1.1 加载
    1.1.2 链接
    1.1.3 初始化
    1.1.4 使用
    1.1.5 卸载
    1.2 类加载器
    1.2.1 启动类加载器(Bootstrap ClassLoader)
    1.2.2 扩展类加载器(extensions class loader)
    1.2.3 Apps(系统)类加载器(system class loader)
    1.2.4 用户自定义类加载器
    1.3 双亲委派机制
    1.3.1 双亲委派机制
    1.3.2 双亲委派模型工作工程:
    1.3.3 双亲委派好处
    1.3.4 双亲委派特例(Thread Context ClassLoader)

  2. 内存模型(JMM)
    运行时数据区
    2.1 什么是内存模型?
    释义
    2.1.1 JMM 解决问题
    2.2 JMM 实现
    2.1 原子性
    2.2可见性(缓存一致性)
    2.3有序性
    2.4 Happens-Before

  3. 内存结构
    3.1 堆(Heap)线程共享
    3.2 方法区(Method Area)线程共享
    MetaSpace 元空间
    运行时常量池
    3.3 虚拟机栈(JVM stack)线程私有
    栈帧组成
    3.4 Native本地方法栈 线程私有
    3.5 程序计数器 (pc registeer)线程私有
    3.6 直接内存
    堆外内存使用
    3.7 小总结:

  4. 对象
    4.1 对象生命周期
    4.1.1 创建阶段(Creation)
    4.1.2 应用阶段(Using)
    4.1.3 不可视阶段(Invisible)
    4.1.4 不可到达阶段(Unreachable)
    4.1.5 可收集阶段(Collected)
    4.1.6 终结阶段(Finalized)与释放阶段(Free)
    4.2 对象的访问
    4.2.1 值传递
    4.2.1 引用传递
    4.2.3 对象访问定位
    4.3 对象内存结构
    4.3.1 对象头
    4.3.2 实例数据
    4.3.3 对齐填充

  5. GC 如何确定一个对象是垃圾
    5.1. 引用计数法
    5.2 可达性分析(根搜索法)
    5.2.1 GC ROOT 对象
    5.2.2 可达性分析为什么要停止STW?
    5.2.3 OopMap结构(safe point)
    5.2.4 真正宣告死亡(两次标记)
    5.3 什么时候会垃圾回收

  6. GC 垃圾收集算法
    6.1 标记-清除(Mark-Sweep)
    6.2 标记-复制((Mark-Copying)
    6.3 标记-整理(Mark-Compact)
    6.4 分代收集算法
    6.4.1 为什么要分代
    6.4.2 对象的创建与GC
    6.5 GC 类别

  7. 垃圾收集器
    7.1 Serial 收集器
    7.2 ParNew 收集器
    7.3 Parallel Scavenge 收集器
    7.4 Serial Old 收集器
    7.5 Parallel Old 收集器
    7.6 CMS收集器(Concurrent Mark Sweep)
    7.6.1 使用条件
    7.6.2 CMS收集的方法是:
    7.7 G1收集器 (garbage frist)
    7.8 ZGC全并发(Z Garbage Collector)
    7.9 垃圾收集器总结:
    7.9.1 吞吐量和停顿时间
    7.9.2 如何开启需要的垃圾收集器

  8. 常用参数:

  9. jvm 优化
    性能优化 (突破现有瓶颈)
    jvm 层面性能优化

JVM

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。

引入Java虚拟机后,Java语言在不同平台上运行时不需要重新编译。Java语言使用Java虚拟机屏蔽了与具体平台相关的信息,使得Java语言编译程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。

1. 类加载

Java 语言是一种具有动态性的解释型语言,类(Class)只有被加载到 JVM 后才能运行。一次编译,到处运行。当运行指定程序时,JVM 会将编译生成的 .class 文件按照需求和一定的规则加载到内存中,并组织成为一个完整的 Java 应用程序。这个加载过程是由类加载器完成,具体来说,就是由 ClassLoader 和它的子类来实现的。类加载器本身也是一个类,其实质是把类文件从硬盘读取到内存中。类的加载方式分为隐式加载和显示加载。隐式加载指的是程序在使用 new 等方式创建对象时,会隐式地调用类的加载器把对应的类加载到 JVM 中。显示加载指的是通过直接调用 class.forName() 方法来把所需的类加载到 JVM 中。

任何一个工程项目都是由许多类组成的,当程序启动时,只把需要的类加载到 JVM 中,其他类只有被使用到的时候才会被加载,采用这种方法一方面可以加快加载速度,另一方面可以节约程序运行时对内存的开销。此外,在 Java 语言中,每个类或接口都对应一个 .class 文件,这些文件可以被看成是一个个可以被动态加载的单元,因此当只有部分类被修改时,只需要重新编译变化的类即可,而不需要重新编译所有文件,因此加快了编译速度。

在 Java 语言中,类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类(例如基类)完全加载到 JVM 中,至于其他类,则在需要的时候才加载

总结:

虚拟机把Class文件加载到内存 。并对数据进行校验,转换解析和初始化 。形成可以虚拟机直接使用的Java类型,即java.lang.Class

1.1 类加步骤

1. 加载

查找和导入class文件

此阶段使用类加载器。通过一个类的全限定名获取定义此类的二进制字节流(.class文件),通过全称限定名获取类的二进制流。将二进制流中的静态存储结构化方法转还为方法区运行时数据结构也就是方法区中存储的类信息。在堆内存中生成该类的 java.lang.Class 对象,作为该类的数据访问入口。(这里只是一个引用,并不是把类放入堆中)

Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。在 Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口

堆指向方法区

2. 链接

  1. 验证:保证被加载类的 class 文件格式的正确性 ,为了确保class文件字节流中的信息不会危害到虚拟机

    • 文件格式验证:验证字节流是否符合class 文件的规范,主次版本号是否在当前虚拟机范围内,常量池中的常量是否有不被支持的类型。
    • 元数据验证:对字节码描述的信息进行语义分析,如类是否有父类,是否继承了不被继承的类等
    • 字节码验证:整个验证过程中最复杂的一个阶段,通过验证数据流和控制流的分析,确定程序语义是否正确,主要真对方法体的验证。如:方法中的类型转换是否正确,跳转指令是否正确等。
    • 符号引用验证
  2. 准备:在方法区中为静态变量分配内存,并将其初**始化为默认值。(此时默认值皆为空,在初始化阶段赋值)**准备阶段不分配类中的实例变量内存。实例变量将会在对象实例化时随着对象分配在Java堆中。

  3. 解析:将符号引用转化为值引用。因为准备阶段已经分配内存了,所以这一步是将 符号引用(符号引用是指class 中的字面量。) 转换为 直接引用,就是直接指向目标的指针(内存地址)相对偏移量或一个间接定位到目标的句柄

3. 初始化

静态变量和静态代码块执行初始化工作。也就是为静态变量赋值。是初始化是类加载的最后一步。

前面的类加载过程,除了在加载时可以选择加载器,其他阶段完全由虚拟机主导,初始化阶段,才开始执行类中定义的Java程序代码

4. 使用

5. 卸载

1.2 类加载器

1. 启动类加载器(Bootstrap ClassLoader)

用来加载 java 核心类库,无法被 java 程序直接引用。

启动类加载器(Bootstrap ClassLoader),它是属于虚拟机自身的一部分,用C++实现的,主要负责加载<JAVA_HOME>\\lib\\rt.jar目录中或被-Xbootclasspath指定的路径中的并且文件名是被虚拟机识别的文件。它等于是所有类加载器的爸爸

2. 扩展类加载器(extensions class loader)

用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。

扩展类加载器(Extension ClassLoader),它是Java实现的,独立于虚拟机,主要负责加载<JAVA_HOME>\\lib\\ext目录中或被java.ext.dirs系统变量所指定的路径的类库。

3. Apps(系统)类加载器(system class loader)

它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过ClassLoader.getSystemClassLoader()来获取它应用程序类加载器(Application ClassLoader),它是Java实现的,独立于虚拟机。主要负责加载用户类路径(classPath)上的类库,如果我们没有实现自定义的类加载器那这玩意就是我们程序中的默认加载器。

4. 用户自定义类加载器

通过继承 java.lang.ClassLoader 类的方式实现

1.3 双亲委派机制

1.3.1 双亲委派机制

双亲委派机制是指当一个类加载器收到一个类加载请求时,该类加载器首先会把请求委派给父类加载器。每个类加载器都是如此,只有在父类加载器在自己的搜索范围内找不到指定类时,子类加载器才会尝试自己去加载。

1.3.2 双亲委派模型工作工程:

1.当 Application ClassLoader 收到一个类加载请求时,他首先不会自己去尝试加载这个类,而是将这个请求委派给父类加载器Extension ClassLoader去完成。

2.当Extension ClassLoader收到一个类加载请求时,他首先也不会自己去尝试加载这个类,而是将请求委派给父类加载器Bootstrap ClassLoader去完成。

3.如果Bootstrap ClassLoader加载失败(在<JAVA_HOME>\\lib中未找到所需类),就会让Extension ClassLoader尝试加载。

4.如果Extension ClassLoader也加载失败,就会使用Application ClassLoader加载。

5.如果Application ClassLoader也加载失败,就会使用自定义加载器去尝试加载。

6.如果均加载失败,就会抛出ClassNotFoundException异常

1.3.3 双亲委派好处

双亲委派有啥好处呢?

它使得类有了层次的划分。就拿java.lang.Object来说,你加载它经过一层层委托最终是由Bootstrap ClassLoader来加载的,也就是最终都是由Bootstrap ClassLoader去找<JAVA_HOME>\\lib中rt.jar里面的java.lang.Object加载到JVM中。

这样如果有不法分子自己造了个java.lang.Object,里面嵌了不好的代码,如果我们是按照双亲委派模型来实现的话,最终加载到JVM中的只会是我们rt.jar里面的东西,也就是这些核心的基础类代码得到了保护

1.3.4 双亲委派特例(Thread Context ClassLoader)

如果出现 基础类又要调用用户的代码情况会破坏双亲委派

典型的例子便是JDBC,你先得知道SPI(Service Provider Interface),这玩意和API不一样,它是面向拓展的,也就是我定义了这个SPI,具体如何实现由**扩展者(各个厂商)**实现。我就是定了个规矩。

JDBC就是如此,在rt.jar里面定义了这个SPI,那mysql有mysql的jdbc实现,oracle有oracle的jdbc实现,java不管内部如何实现的,反正都得统一按我这个SPI来,这样java开发者才能容易的调用数据库操作。所以因为这样那就不得不违反双亲委派,Bootstrap ClassLoader就得委托子类来加载数据库厂商们提供的具体实现。因为 Bootstrap ClassLoader 只能加载 <JAVA_HOME>\\lib中的类,其他的它无能为力。这就违反了自下而上的委托机制了。

为了解决这个困境,Java设计团队引入了一个不太优雅的设计:线程上下文件类加载器(Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader()方法进行设置类加载器,如果创建线程时还未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是应用程序类加载器

有了线程上下文类加载器,JDBC服务使用这个线程上下文类加载器去加载所需要的SPI代码,也就是父类加载器请求子类加载器去完成类加载动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型,但这也是无可奈何的事情。Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI,JDBC,JCE,JAXB和JBI等。

OSGi、tomcat 等也都违反了双亲委派机制

2. 内存模型(JMM)

2.1 什么是内存模型?

Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能得到一致效果的机制及规范。目的是解决由于多线程通过共享内存进行通信时,存在的原子性、可见性(缓存一致性)以及有序性问题。

JMM是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。

释义

Java内存模型 其实是保证了Java程序在各种平台下对内存的访问都能够得到一致效果的机制及规范。目的是解决由于多线程通过共享内存进行通信时,存在的原子性可见性(缓存一致性)以及有序性问题。

除此之外,Java内存模型还提供了一系列原语,封装了底层实现后,供开发者直接使用。如我们常用的一些关键字:synchronized、volatile以及并发包等。

2.1.1 JMM 解决问题

原子性是指在一个操作中就是cpu不可以在中途暂停然后再调度,既不被中断操作,要不执行完成,要不就不执行。

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

有序性即程序执行的顺序按照代码的先后顺序执行。

缓存一致性问题其实就是可见性问题。而处理器优化是可以导致原子性问题的。指令重排即会导致有序性问题。所以,后文将不再提起硬件层面的那些概念,而是直接使用大家熟悉的原子性、可见性和有序性。

2.2 JMM 实现

2.1 原子性

线程是CPU调度的基本单位。CPU有时间片的概念,会根据不同的调度算法进行线程调度。所以在多线程场景下,就会发生原子性问题。因为线程在执行一个读改写操作时,在执行完读改之后,时间片耗完,就会被要求放弃CPU,并等待重新调度。这种情况下,读改写就不是一个原子操作。即存在原子性问题。

解决

在Java中,为了保证原子性,提供了两个高级的字节码指令monitorentermonitorexit。在synchronized的实现原理文章中,介绍过,这两个字节码,在Java中对应的关键字就是synchronized

因此,在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的。

2.2可见性(缓存一致性)

在多核CPU,多线程的场景中,每个核都至少有一个L1 缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的caehe中保留一份共享内存的缓冲。由于多核是可以并行的,可能会出现多个线程同时写各自的缓存的情况,而各自的cache之间的数据就有可能不同。

在CPU和主存之间增加缓存,在多线程场景下就可能存在缓存一致性问题,也就是说,在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致。

解决

Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。

Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。

除了volatile,Java中的synchronizedfinal两个关键字也可以实现可见性。只不过实现方式不同,这里不再展开了。

synchronized 是通过加锁,使线程顺序执行。所以保证了可见性。而volatile 是通过打破内存屏障实现的可见性。

2.3有序性

除了引入了时间片以外,由于处理器优化指令重排等,CPU还可能对输入代码进行乱序执行,比如load->add->save 有可能被优化成load->save->add 。这就是有序性问题。

为了使处理器内部的运算单元能够尽量的被充分利用,处理器可能会对输入代码进行乱序执行处理。这就是处理器优化

除了现在很多流行的处理器会对代码进行优化乱序处理,很多编程语言的编译器也会有类似的优化,比如Java虚拟机的即时编译器(JIT)也会做指令重排

解决

在Java中,可以使用synchronizedvolatile来保证多线程之间操作的有序性。实现方式有所区别:

volatile关键字会禁止指令重排synchronized关键字加锁保证同一时刻只允许一条线程操作。

2.4 Happens-Before 规则

因为jvm会对代码进行编译优化,指令会出现重排序的情况,为了避免编译优化对并发编程安全性的影响,需要happens-before规则定义一些禁止编译优化的场景,保证并发编程的正确性

我们编写的程序都要经过优化后(编译器和处理器会对我们的程序进行优化以提高运行效率)才会被运行,优化分为很多种,其中有一种优化叫做重排序,重排序需要遵守happens-before规则

在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系

原则定义:

1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

2. 两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。

坦白来说:
前一个操作的结果可以被后续的操作获取。讲白点就是前面一个操作把变量a赋值为1,那后面一个操作肯定能>知道a已经变成了1。

happens-before原则规则:

  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;(同一个线程中前面的所有写操作对后面的操作可见
  2. 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作;(如果线程1解锁了monitor a,接着线程2锁定了a,那么,线程1解锁a之前的写操作都对线程2可见(线程1和线程2可以是同一个线程)
  3. volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;(如果线程1写入了volatile变量v(临界资源),接着线程2读取了v,那么,线程1写入v及之前的写操作都对线程2可见(线程1和线程2可以是同一个线程)
  4. 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
  5. 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作; (假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行前对线程B可见。注意:线程B启动之后,线程A在对变量修改线程B未必可见
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;((线程t1写入的所有变量,调用Thread.interrupt()中断线程2,被打断的线程t2,可以看到t1的全部操作))
  7. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;(线程t1写入的所有变量,在任意其它线程t2调用t1.join(),或者t1.isAlive() 成功返回后,都对t2可见。
  8. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;(一个对象的初始化的完成,也就是构造函数执行的结束一定 happens-before它的finalize()方法)

一言以蔽之,这些规则背后的道理

在程序运行过程中,所有的变更会先在寄存器或本地cache中完成,然后才会被拷贝到主存以跨越内存栅栏(本地或工作内存到主存之间的拷贝动作),此种跨越序列或顺序称为happens-before。
注:happens-before本质是顺序,重点是跨越内存栅栏
通常情况下,写操作必须要happens-before读操作,即写线程需要在所有读线程跨越内存栅栏之前完成自己的跨越动作,其所做的变更才能对其他线程可见。

借用大神的话

再有人问你Java内存模型是什么,就把这篇文章发给他:https://www.hollischuang.com/archives/2550?spm=a2c6h.12873639.0.0.48a823a8pgML6K
既生synchronized,何生volatile:https://developer.aliyun.com/article/715256

3. 内存结构

运行时数据区

运行时数据区,其实重点存储数据的是堆和方法区(非堆),所以内存的设计也着重从这两方面展开(注意这两块区域都是线程共享的)。对于虚拟机栈,本地方法栈,程序计数器都是线程私有的。

可以这样理解,JVM运行时数据区是一种规范,而JVM内存结构是对该规范的实现

3. 1 堆(Heap)线程共享

Java堆是Java虚拟机所管理内存中最大的一块,在虚拟机启动时创建。Java对象实例以及数组都在堆上分配。由 Java 虚拟机自动垃圾回收器管理,存取速度慢,在虚拟机启动时创建,

jdk1.8后常量池和静态变量放在堆内存中,类信息和编译代码放在元空间

3. 2 方法区(Method Area)线程共享

在虚拟机启动时创建,保存jvm加载的类信息(包括接口、成员变量,方法信息)常量,静态变量,字节码、即时编译器(JIN)编译之后的代码。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却又一个别名叫做Non-Heap(非 堆),目的是与Java堆区分开来。当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常

JVM运行时方法区是一种规范,真正的实现。在JDK 8中就是Metaspace,在JDK6或7中就是Perm Space

MetaSpace 元空间

为什么要移出永久代用元空间?

方法区规范再 jdk8 通过元空间(Metaspace)实现。并移出了jvm内存,使用机器直接内存。改进是 为了防止程序类太多,程序启动占用太多的堆内存,导致程序启动便 内存溢出了

  • 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出
  • 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低

运行时常量池

是方法区的一部分,class文件除了有类的字段、接口、方法等描述信息之外,还有常量池用于存放编译期间生成的各种字面量和符号引用。

字面量:final 修饰、文本、字符串

符号引用:类信息(包括字段)接口、方法、明细数据、描述。

3. 3 虚拟机栈(JVM stack)线程私有

虚拟机栈是一个线程执行的区域,保存着一个线程中方法的调用状态。换句话说,一个Java线程的运行状态,由一个虚拟机栈来保存,所以虚拟机栈是线程私有的,独有的,随着线程的创建而创建。每一个被线程执行的方法,为该栈中的栈帧,即每个方法对应一个栈帧。

栈的结构是由多个栈帧(invoke)组成的,调用一个方法就压入一帧,一个方法调用完成,就会把该栈帧从栈中弹出。帧上面存储局部变量表,操作数栈,方法出口等信息,局部变量表存放的是 8 大基础类型加上一个应用类型,所以还是一个指向地址的指针

栈中的栈帧随着方法的进入顺序的执行的入栈和出栈的操作,一个栈帧需要分配多少内存取决于具体的虚拟机实现并且在编译期间即确定下来【忽略JIT编译器做的优化,基本当成编译期间可知】,当方法或线程执行完毕后,内存就随着回收

当出现了异常,就会出现我们经常见到堆栈跟踪信息printStackTrace(); 而栈帧stack frame包含当前执行方法所属类的本地变量组,操纵数栈,以及运行时常量池引用。

JDK 5以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。-Xss128k:设置每个线程的堆栈大小。 一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000

Java中每个线程都有自己的线程栈,如上所述,线程栈包含了当前线程调用方法当前执行的相关信息。每个线程只能访问自己的线程栈,线程创建的本地变量对其它线程不可见。

值得提及的是,如果一个本地变量(即线程中的变量)是原始类型,则它会创建在线程栈上;但如果一个本地变量是指向一个对象的一个引用,则此时,这个本地变量引用会在线程栈,而且引用的对象本身则是放在上的(无论是哪个线程创建的)。当多个线程同时访问一个对象的成员变量时,每一个线程都会复制这个本地变量的私有版本

共享对象可见性:多个线程操作一个共享对象,一个线程的更新有可能对其它线程不可见。如,跑在cpu1的线程更新了一个共享对象;只要cpu缓存没有刷新回主存(此时在cpu寄存器中存储),则其更改对其它cpu线程不可见。从而引出了我们后即将介绍的volatile关键字。

栈帧组成

  • 局部变量:

    存储:方法中定义的局部变量以及方法的参数存放在这张表中

    局部变量表中的变量不可直接使用,如需要使用的话,必须通过相关指令将其加载至操作数栈中作为操作数使

    用。

    如果一个本地变量(即线程中的变量)是原始类型,则它会创建在线程栈上;但如果一个本地变量是指向一个对象的一个引用,则此时,这个本地变量引用会在线程栈,而且引用的对象本身则是放在上的(无论是哪个线程创建的)。当多个线程同时访问一个对象的成员变量时,每一个线程都会复制这个本地变量的私有版本

    线程间数据操作不可见是因为,每个CPU执行时只能执行一个线程,所以会将线程的数据加载在CPU的缓存,CPU操作时会直接操作CPU一级缓存的数据,当计算结束后,将缓存中的数据刷入主存储器堆中。多核CPU同时执行多线程时就会出现脏数据问题,所以引入了volatile 关键字,打破内存屏障

  • 操作数栈

    以压栈和出栈的方式存储操作数的

  • 动态链接

    每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,是符号引用转化为直接引用。是Java 多态特性的实现,类在运行阶段,才会确定调用接口的哪一个子类。此时将符号引用转化为直接引用。也就是将 符号变量转化为 实际调用 实现类的地址 。 为了实现动态的调用过程。

  • 返回地址

    当一个方法开始执行后,只有两种方式可以退出,一种是遇到方法返回的字节码指令;一种是遇见异常,并且这个异常没有在方法体内得到处理

  • 附加信息

3. 4 Native本地方法栈 线程私有

如果当前线程执行的方法是Native类型的,这些方法就会在本地方法栈中执行。

本地方法栈是与虚拟机栈发挥的作用十分相似, 区别是虚拟机栈执行的是Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的native方法服务,可能底层调用的c或者c++,我们打开jdk安装目录可以看到也有很多用c编写的文件,可能就是native方法所调用的c代码。

3. 5 程序计数器 (pc registeer)线程私有

程序计数器是一个比较小的内存区域,用于指示当前线程所执行的字节码执行到了第几行,可以理解为是当前线程的行号指示器。字节码解释器在工作时,会通过改变这个计数器的值来取下一条语句指令。如果线程正在执行Java方法,则计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,则这个计数器为空

用于解决,cpu 时间片到期后,线程下次获取到cpu执行权后知道上次执行到那一条语句了,然后从那一条语句开始继续执行。

3. 6 直接内存

堆外内存使用

3.7 小总结:

Object obj=new Object() 栈中元素指向堆中对象。obj 存在栈中,new Object() 实例存储在堆中。

private static Object obj=new Object(); 方法区中元素指向堆中对象。obj 存在方法区,new Object() 实例存储在堆中。

不要忘记加载类 时,堆指向方法区的案例“java.lang.class” 的类信息入口。

4. 对象

4.1 对象生命周期

对象生命周期分为6个阶段分别为 创建阶段(Creation)、应用阶段(Using)、不可视阶段(Invisible)、不可到达阶段(Unreachable)、可收集阶段(Collected)、终结阶段(Finalized)与释放阶段(Free)。

4.1.1 创建阶段(Creation)

创建阶段为对象分配内存,调用构造函数,初始化属性。当对象创建后会存储在JVM的heap堆中等待使用

  • 为对象分配存储空间
  • 开始构造对象
  • 从父类到子类对static成员进行初始化
  • 父类成员按顺序初始化,递归调用父类的构造方法
  • 子类成员变量按顺序初始化,调用子类的构造方法

一旦对象被创建,并分派给某些变量赋值,这个对象的状态就切换到了应用状态

4.1.2 应用阶段(Using)

对象至少被一个强引用持有称为处于应用阶段,一个对象可以有多个强引用,GC不会回收处于应用状态的对象

  • 强引用(Reference)

    • 把一个对象赋给一个引用变量,这个引用变量就是一个强引用。
    • 强引用是我们最常见的普通对象引用,只要还有强引用指向一个对象,就能表明对象还活着
    • 对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显示的将引用赋值为null,GC就会回收这个对象了。

    如果一个对象具有强引用,那就类似于必不可少的物品,不会被垃圾回收器回收。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不回收这种对象。

    Dog dog = new Dog(); 
    
  • 软引用(SoftReference)

    • 软引用是一种相对强化引用弱化了一些引用,需要使用java.lang.SoftReference类来实现。
    • 对于只有软引用的对象来说,
    • 当系统内存充足时,不会被回收;
    • 当系统内存不足时,会被回收
    • 软引用适合用于缓存,当内存不足的时候把它删除掉,使用的时候再加载进来
    • 软引用可以与引用队列(ReferenceQueue)联合使用。
    MyObject aRef = new MyObject();
    SoftReference aSoftRef = new SoftReference(aRef); 
    
  • 弱引用(WeakReference)

    • 弱引用需要用java.lang.WeakReference类来实现,它比软引用的生存期更短。
    • 如果一个对象只是被弱引用引用者,那么只要发生GC,不管内存空间是否足够,都会回收该对象。可以配合ReferenceQueue 使用
    • 弱引用适合解决某些地方的内存泄漏的问题
    • ThreadLocal静态内部类ThreadLocalMap中的Entiry中的key就是一个虚引用;
  • 虚引用(PhantomReference)

    • 虚引用需要 java. langref.PhantomReference类来实现。
    • 顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。
    • 如果一个对象仅被虛引用持有,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收
    • 它不能单独使用也不能通过它访问对象,虚引用必须和引用队列( Reference queue)联合使用
    • 虚引用的主要作用是跟踪对象被垃圾回收的状态。仅仅是提供了一种确保对象被 finalize以后,做某些事情的机制
    • PhantomReference的get方法总是返回null,因此无法访问对应的引用对象。
    • 使用它的意义在于说明一个对象已经进入 finalization阶段,可以被回收,用来实现比 finalization机制更灵活的回收操作换句话说,设置虚引用关联的唯一目的,就是在这个对象被收集器回收的时候收到一个系统通知或者后续添加进一步的处理;
    • 虚引用用来管理堆外内存
  • 引用队列(ReferenceQueue)

    我们希望当一个对象被gc掉的时候通知用户线程,进行额外的处理时,就需要使用引用队列了。ReferenceQueue即这样的一个对象,当一个obj被gc掉之后,其相应的包装类,即ref对象会被放入queue中。我们可以从queue中获取到相应的对象信息,同时进行额外的处理。比如反向操作,数据清理等

    • 对象在被回收之前要被引用队列保存一下。GC之前对象不放在队列中,GC之后才对象放入队列中。
    • 【通过开启线程监听该引用队列的变化情况】就可以在对象被回收时采取相应的动作。
    • 由于虚引用的唯一目的就是能在这个对象被垃圾收集器回收时能收到系统通知,因而创建虚引用时必须要关联一个引用队列而软引用和弱引用则不是必须的。
    • 这里所谓的收到系统通知其实还是通过开启线程监听该引用队列的变化情况来实现的。
    • 这里还需要强调的是,
    • 对于软引用和弱引用,当执行第一次垃圾回收时,就会将软引用或弱引用对象添加到其关联的引用队列中,然后其finalize函数才会被执行(如果没复写则不会被执行);
    • 而对于虚引用,如果被引用对象没有复写finalize方法,则是在第一次垃圾回收将该类销毁之后,才会将虚拟引用对象添加到引用队列,
    • 如果被引用对象复写了finalize方法,则是当执行完第二次垃圾回收之后,才会将虚引用对象添加到其关联的引用队列
    • 一个对象的finalize()方法只会被调用一次,而且finalize()被调用不意味着gc会立即回收该对象,所以有可能调用finalize()后, 该对象又不需要被回收了,然后到了真正要被回收的时候,因为前面调用过一次,所以不会调用finalize(),产生问题,所以,推荐不要使用finalize()方法

4.1.3 不可视阶段(Invisible)

强引用对象超出其作用域之后就变为不可见,当一个对象处于不可见阶段是,说明程序本身不再持有该对象的不论强弱引用,尽管这些引用仍然是存在的。

4.1.4 不可到达阶段(Unreachable)

在虚拟机所管理的对象引用根集合再也找不到直接或间接的强引用,这些对象通常是指所有线程栈中的临时变量,所有已装载的类的静态变量或者本地代码接口的引用。这些对象都是要被垃圾回收回收的预备对象,但此时该对象并不能被垃圾回收器直接回收。其实所有垃圾回收算法所面临的问题是相同的——找出由分配器分配的,但是用户程序不可到达的内存块。

4.1.5 可收集阶段(Collected)

当垃圾回收器发现该对象已经处于“不可达阶段”而且垃圾回收器已经对该对象内存空间的又一次分配做好了准备是,则对象进入了“收集阶段”。假设该对象已经重写了finalize方法,则回去运行该方法的终端操作。

注意:不要重载finazlie()方法

  • 会影响JVM的对象分配与回收速度在分配该对象时,JVM要在垃圾回收器上注册该对象,以便在回收时可以运行该重载方法;在该方法的运行时要消耗CPU时间且在运行完该方法后才会又一次运行回收操作,即至少需要垃圾回收器对该对象运行两次GC

  • 可能造成该对象的再次“复活”在finzlize()方法中,**假设有其它的强引用再次持有该对象,则会导致对象的状态由“收集阶段”又一次变为“应用阶段”。**这个已经破坏了Java对象的生命周期进程,且“复活”的对象不利于代码管理。

4.1.6 终结阶段(Finalized)与释放阶段(Free)

当对象运行完finalize()方法后仍然处于不可达状态时,则该对象进入终结阶段。在该阶段是等待垃圾回收器对该对象空间进行回收。

垃圾回收器对该对象的所占内存空间进行回收或者再分配了,则该对象彻底消失了,称之为“对象空间的又一次分配阶段”。

4.2 对象的访问

4.2.1 值传递

值传递是在程序设计中,对于函数调用的一种方法,值引用只是把值传递到新的变量,修改新的变量,不会修改原来的参数。

基本数据类型的传递:基本数据类型的值就保存在变量中,传递的是基本类型的字面量值的拷贝,当发生传递时并不会改变原来的变量值。

4.2.1 引用传递

所谓引用传递是指在调用函数时将实际参数的地址传递到函数中,那么在函数中对参数所进行的修改,将影响到实际参数

变量中保存的是实际对象的地址,传递的是堆中对象的一个拷贝(也就是引用),操作的并不是堆中的对象本身,在数据传递时原来的引用地址会被覆盖,赋值运算会改变原来引用中保存的地址,但是堆中的对象本身不会被改变

4.2.3 对象访问定位

java程序需要通过引用(ref)数据来操作堆上面的对象,那么如何通过引用定位、访问到对象的具体位置

Object object = new Object();

设这句代码出现在方法体中,"Object object” 这部分将会反映到Java栈的本地变量中,作为一个reference类型数据出现

reference类型在java虚拟机规范里面只规定了一个指向对象的引用地址,并没有定义这个引用应该通过那种方式去定位,访问到java堆中的对象位置,因此不同的虚拟机实现的访问方式可能不同,主流的方式有两种:使用句柄和直接指针

句柄访问

简单来说就是java堆划出一块内存作为句柄池, ref引用中存储对象的句柄地址 ,句柄中包含对象实例数据、类型数据的地址信息。

优点:引用中存储的是稳定的句柄地址,在对象被移动【垃圾收集时移动对象是常态】只需改变句柄中实例数据的指针,不需要改动引用【ref】本身

就虚拟机而言,它使用的是第二种方式(直接指针访问)

直接指针(直接访问)

与句柄访问不同的是,ref中直接存储的就是对象的实例数据地址,但是对象类型数据跟句柄访问方式一样。

优点:优势很明显,就是速度快,相比于句柄访问少了一次指针定位的开销时间。【可能是出于Java中对象的访问时十分频繁的,平时我们常用的JVM HotSpot采用此种方式】就虚拟机而言,它使用的是第二种方式(直接指针访问)

4.3 对象内存结构

一个Java对象在内存中包括3个部分:对象头、实例数据和对齐填充

4.3.1 对象头

在32位系统下,对象头8字节,64位则是16个字节【未开启压缩指针,开启后12字节】。对象头中存储了对象的hashcode、分代年龄、锁状态、锁标志位对于普通对象而言,其对象头中有两类信息:mark word和类型指针。另外对于数组而言还会有一份记录数组长度的数据。

对象头含义

上图为32位系统上各状态的格式

当对象状态为**偏向锁(**biasable)时,mark word存储的是偏向的线程ID;

当状态为轻量级锁(lightweight locked)时,mark word存储的是指向线程栈中Lock Record的指针;

当状态为重量级锁(inflated)时,为指向堆中的monitor对象的指针。

在操作同步资源之前需要给同步资源先加锁,这把锁就是存在Java对象头里的,以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。

  • Mark Word(标记字段)d:默认存储对象的HashCode,GC分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。在32位系统上mark word长度为32字节,64位系统上长度为64字节
  • **Klass Pointer(类型指针)**t:对象指向它的类元数据的指针,**虚拟机通过这个指针来确定这个对象是哪个类的实例。

扩展 对象头和锁之间的转换

4.3.2 实例数据

存放对象程序中各种类型的字段类型,不管是从父类中继承下来的还是在子类中定义的。
分配策略:相同宽度的字段总是放在一起,比如double和long

4.3.3 对齐填充

这部分没有特殊的含义,仅仅起到占位符的作用满足JVM要求。

由于HotSpot规定对象的大小必须是8的整数倍,对象头刚好是整数倍,如果实例数据不是的话,就需要占位符对齐填充。最早是因为 cpu 存储是规定 8u 8(8个8位无符号存储) 64 位。以8位保存

听大神的话:

Java对象的生命周期:https://www.jianshu.com/p/af1edcaa3e89

5. GC 如何确定一个对象是垃圾

GC 关心区域 是堆,

GC 的重点是

  • 精准 高效收集垃圾
  • 垃圾收集器

5.1. 引用计数法

早期判断对象是否存活大多都是以这种算法,这种算法判断很简单,简单来说就是给对象添加一个引用计数器,每当对象被引用一次就加1,引用失效时就减1。当为0的时候就判断对象不会再被引用。

优点: 实现简单效率高,被广泛使用与如python何游戏脚本语言上。

缺点: 难以解决循环引用的问题,就是假如两个对象互相引用已经不会再被其它其它引用,导致一直不会为0就无法进行回收。

5.2 可达性分析(根搜索法)

目前主流的商用语言[如java、c#]采用的是可达性分析算法判断对象是否存活。这个算法有效解决了循环利用的弊端。
它的基本思路是通过一个称为“GC Roots”的对象为起始点,搜索所经过的路径称为引用链,当一个对象到GC Roots没有任何引用跟它连接则证明对象是不可用的

即使可达性算法中不可达的对象,也不是一定要马上被回收,还有可能被抢救一下

5.2.1 GC ROOT 对象

可作为GC Roots的对象有四种:

①虚拟机栈(栈桢中的本地变量表)中的对象引用,就是平时所指的java对象,存放在堆中

②方法区中的类静态属性引用的对象,一般指被static修饰引用的对象,加载类的时候就加载到内存中。

③方法区中的常量引用的对象,

④本地方法栈中JNI(native方法)引用的对象

5.2.2 可达性分析为什么要停止STW?

为了避免该回收的没回收,不该回收的回收了。不该回收的回收了不可饶恕。

应用线程运行时,对象的引用会不停的发生变化,可能会导致已被可达性分析后的可达对象,不被引用了造成该回收的没回收。已被可达性分析后的不可达对象,因为系统运行,变为可达。但是已经被标记为回收对象了,造成不该回收的回收了。

5.2.3 OopMap结构(safe point)

问题:

1.如果方法区几百兆,一个个检查里面的引用,将耗费大量资源。

2.在分析时,需保证这个对象引用关系不再变化,否则结果将不准确。【因此GC进行时需停掉其它所有java执行线程(Sun把这种行为称为‘Stop the World’),即使是号称几乎不会停顿的CMS收集器,枚举根节点时也需停掉线程】

解决

实际上当系统停下来后JVM不需要一个个检查引用,而是通过OopMap数据结构【HotSpot的叫法】来标记对象引用。 在类加载完时。HotSpot把对象内什么偏移量什么类型的数据算出来,在jit编译过程中,也会在特定位置记录下栈和寄存器哪些位置是引用,这样GC在扫描时就可以知道这些信息。【目前主流JVM使用准确式GC

OopMap可以帮助HotSpot快速且准确完成GC Roots枚举以及确定相关信息。但是也存在一个问题,可能导致引用关系变化。

这个时候有个safepoint(安全点)的概念。safepoint 安全点是指一些特定的位置,当线程运行到这些位置时,线程的一些状态可以被确定,比如记录OopMap的状态,从而确定GC Root的信息,使JVM可以安全的进行一些操作,比如开始GC。HotSpot中GC不是在任意位置都可以进入,而只能在safepoint处进入。GC的标记阶段需要stop the world,让所有Java线程挂起,这样JVM才可以安全地来标记对象。safepoint 可以用来实现让所有Java线程挂起的需求。

当JVM需要让Java线程进入safepoint的时候,只需要设置一个标志位,让Java线程运行到safepoint的时候主动检查这个标志位,如果标志被设置,那么线程停顿,如果没有被设置,那么继续执行。

safepoint不能太少,否则GC等待的时间会很久

safepoint不能太多,否则将增加运行GC的负担

安全点主要存放的位置:

1:循环的末尾

2:方法临返回前/调用方法的call指令后

3:可能抛异常的位置

5.2.4 真正宣告死亡(两次标记)

要真正宣告对象死亡需经过两个过程。
1.可达性分析后没有发现引用链

2.查看对象是否有finalize方法,如果有重写且在方法内完成自救[比如再建立引用],还是可以抢救一下,注意这边一个类的finalize只执行一次,这就会出现一样的代码第一次自救成功第二次失败的情况。

[如果类重写finalize且还没调用过,会将这个对象放到一个叫做F-Queue的序列里,这边finalize不承诺一定会执行,这么做是因为如果里面死循环的话可能会使F-Queue队列处于等待,严重会导致内存崩溃,这是我们不希望看到的。]

5.3 什么时候会垃圾回收

(1)当Eden区或者S区不够用了

(2)老年代空间不够用了

(3)方法区空间不够用了

(4)System.gc()

GC是由JVM自动完成的,根据JVM系统环境而定,所以时机是不确定的。

当然,我们可以手动进行垃圾回收,比如调用System.gc()方法通知JVM进行一次full gc,但是

具体什么时刻运行也无法控制。也就是说System.gc()只是通知要回收,什么时候回收由JVM决

定。但是不建议手动调用该方法,因为GC消耗的资源比较大

听听大神的话

聊聊JVM(九)理解进入safepoint时如何让Java线程全部阻塞:https://blog.csdn.net/ITer_ZC/article/details/41892567

6. GC 垃圾收集算法

6.1 标记-清除(Mark-Sweep)

此算法需要暂停整个应用。此算法执行分两阶段。

第一阶段标记:从引用根节点开始标记所有被引用的对象,递归遍历。此时堆中所有的对象都会被扫描一遍,从而才能确定需要回收的对象,比较耗时

第二阶段清除:遍历整个堆,把未标记的对象清除,同时,会产生内存碎片

缺点

标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程 序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

以上是关于一文明白JVM-万字长文,遇人随便问的主要内容,如果未能解决你的问题,请参考以下文章

一文终结MySQL(万字长文,建议收藏)

万字长文,一文搞懂TCP/IP和HTTPHTTPS

(建议收藏)万字长文,带你一文吃透 Linux 提权

一文读懂mysql-万字长文,肝就完了

史诗级计算机字符编码知识分享,万字长文,一文即懂!

[万字长文]一文带你深入了解Android Gradle