• 剖析 JVM 相关知识点(上)


    一、Java 内存区域(运行时数据区)

    Java 虚拟机在执行 Java 程序的时候回把它管理的内存划分为若干个不同的数据区域。JDK 1.8 和之前的版本略有不同,下面会介绍到。

    JDK 1.8 之前:
    运行时数据区 图标

    JDK 1.8 之后:
    运行时数据区 图标

    线程私有

    • 程序计数器
    • 虚拟机栈
    • 本地方法栈

    线程共享

    • 方法区
    • 直接内存(非运行时数据区的一部分)

    1.1 程序计数器

    程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完。

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

    从上的介绍可知,程序计数器主要有一下两个方面的作用:

    1. 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
    2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

    注意:程序计数器是唯一一个不会出现 OutOfMemeryError 情况的区域。

    1.2 Java 虚拟机栈

    与程序计数器一样,Java 虚拟机栈 (Java Virtual Mechine Stacks) 也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的时候都会创建一个栈帧(Stack Frame) 用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用至执行完成,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。一个方法的调用链可能会很长,于是当调用一个方法时,可能会有很多方法都处于执行状态,但是对于执行引擎来说,置于虚拟机栈顶的帧栈才是有效的这个帧栈称为当前栈,这个方法被称为当前方法,执行引擎的所有指令都是针对当前帧栈进行操作的。

    局部变量表主要存放了编译器可知的各种数据类型(boolean、byte、char、float、double、int、long、short)、对象引用(reference 类型,它不同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)。

    Java 虚拟机栈会出现两种异常: StackOverFlowError 和 OutOfMemeryError**。:

    • StackOverFlowError:若 Java 虚拟机栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈最大深度的时候,就会抛出此异常。
    • OutOfMemeryError:若 Java 虚拟机栈的内存大小允许动态扩展,且当线程请求栈时内存用完了,无法再动态扩展了,此时抛出 OutOfMemeryError。

    Java 虚拟机栈也是线程私有的,每个线程都有各自的 Java 虚拟机栈,而且随着线程的创建而存在,随着线程的死亡而死亡。

    Java 方法有两种返回的方式:

    • return 语句
    • 抛出异常

    不管哪种返回方式,都会导致栈帧被弹出。

    1.3 本地方法栈

    本地方法栈与 Java 虚拟机栈的作用非常相似,区别是:Java 虚拟机栈为 虚拟机执行 Java 方法(也就是字节码) 服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。

    本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

    方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError 和 OutOfMemoryError 两种异常。

    1.4 堆

    对于大多数应用来说,Java 堆是 Java 虚拟机所管理的内存最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此区域的唯一目的就是存放实例对象,几乎所有的对象实例都在这里分配内存。

    Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称为 "GC 堆" (Garbage Collection Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老年代:再细致一点有:Eden空间、From Survivor、To Survivor空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。

    分区 图标

    上图所示的 eden区、s0区、s1区都属于新生代,tentired 区属于老年代。大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden区->Survivor 区后对象的初始年龄变为1),当它的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

    1.5 方法区

    方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。

    方法区也被称为永久代。

    方法区和永久代的关系

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

    常用参数

    JDK 1.8 之前永久代还没被彻底移除的时候通常通过下面这些参数来调节方法区大小

    -XX:PermSize=N //方法区(永久代)初始大小
    -XX:MaxPermSize=N //方法区(永久代)最大大小,超过这个值将会抛出OutOfMemoryError异常:java.lang.OutOfMemoryError: PermGen
    

    相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入方法区后就“永久存在”了。

    JDK 1.8 的时候,方法区(HotSpot的永久代)被彻底移除了(JDK1.7就已经开始了),取而代之是元空间,元空间使用的是直接内存。

    下面是一些常用参数:

    -XX:MetaspaceSize=N //设置Metaspace的初始(和最小大小)
    -XX:MaxMetaspaceSize=N //设置Metaspace的最大大小
    

    与永久代很大的不同就是,如果不指定大小的话,随着更多类的创建,虚拟机会耗尽所有可用的系统内存。

    为什么要将永久代(PermGen)替换为元空间(MetaSpace)呢?

    整个永久代有一个 JVM 本身设置固定大小上线,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,并且永远不会得到 java.lang.OutOfMemoryError。你可以使用 -XX:MaxMetaspaceSize 标志设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。-XX:MetaspaceSize 调整标志定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。

    当然这只是其中一个原因,还有很多底层的原因,这里就不提了。

    1.6 运行时常量池

    运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有常量池信息(用于存放编译期生成的各种字面量和符号引用)

    既然运行时常量池时方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

    JDK1.7 及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。

    常量池 图标

    1.7 直接内存

    直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 异常出现。

    JDK1.4 中新加入的 NIO(New Input/Output) 类,引入了一种基于通道 (Channel)缓存区 (Buffer) 的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。

    本机直接内存的分配不会收到 Java 堆的限制,但是,既然是内存就会受到本机总内存大小以及处理器寻址空间的限制。

    优点:

    • 减少了垃圾回收的工作,因为垃圾回收会暂停其他(可能会使用多线程或者时间片的方式根本感觉不到)
    • 加快了复制的速度。因为堆内存在 flush 到远程时,会优先复制到直接内存,然后再发送;而堆外内存相当于省略了这个工作。

    缺点:

    • 堆外内存难以控制,如果内存泄漏,难以排查;
    • 堆外内存相对来说不适合存储很复杂的对象,一般简单的对象或者扁平化的比较适合。

    二、Java 对象的创建过程

    Java对象的加载 图片
    1.类加载检查:虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
    2.分配内存:在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

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

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

    内存分配 图片

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

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

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

    3.初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
    4.设置对象头:初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
    5.执行 init 方法:在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

    四、Java 中 init 方法和 clinit 方法

    init 方法:
    Java 在编译之后会在字节码文件中生成 文件,称之为实例构造器,该实例构造器会将语句块、变量初始化,调用父类的构造器等操作收敛到 方法中,收敛顺序(这里只讨论非静态变量和语句块)为:

    1. 父类变量初始化;2. 父类语句块;3. 父类构造函数;4. 子类变量初始化;5. 子类语句块;6. 子类构造函数。
    

    所谓收敛到 方法中的意思就是将这些操作放入到 方法中去执行。

    clinit 方法
    Java 在编译后会在字节码文件中生成 方法,称之为类构造器,类构造器同实例构造器一样,也会将静态语句块。静态太变量初始化,收敛到 方法中,收敛顺序为:

    1. 父类静态变量初始化;2. 父类静态语句块;3. 子类静态变量初始化;5. 子类静态语句块。若父类为接口,则不会调用父类的 clinit 方法。一个类可以没有 clinit 方法
    

    方法是在类加载过程中执行的,而 方法是在对象实例化执行的,所以 一定比 先执行。所以整个顺序就是:

    1.父类静态变量初始化;2. 父类静态语句块;3. 子类静态变量初始化;4. 子类静态语句块;5. 父类变量初始化;6. 父类语句块;7. 父类构造函数;8. 子类变量初始化;9. 子类语句块;10. 子类构造函数。
    

    五、堆内存中对象的分配的基本策略

    堆空间的基本结构:

    分区 图标

    上图所示的 eden区、s0区、s1区都属于新生代,tentired 区属于老年代。大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1 (Eden区->Survivor 区后对象的初始年龄变为1),当它的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

    另外,大对象和长期存活的对象会直接进入老年代。

    堆内存分配策略 图片

    六、Minor GC 和 Full GC 有什么不同?

    从年轻代空间(包括 Eden 和 Suvivor 区域)回收内存被称为 Minor GC,对老年代 GC 称为 Major GC。而 Full GC 是对整个堆来说的 ,出现 Full GC 的时候经常伴随至少一次的 Minor GC,但非绝对。Major GC 的速度一般会比 Minor GC 慢 10 倍以上。

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

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

    七、如何判断对象是否死亡?(两种方法)

    堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象以及死亡(即不能再被任何土家使用的对象)

    1. 引用计数法

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

    2. 可达性分析算法

    这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可活的。当对象不可活时,仍然可以通过 finalize() 方法自救。

    可作为 GC Roots 的对象包括

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

    八、四种引用(强引用, 软引用, 弱引用, 虚引用)

    无论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与“引用”有关。

    1. 强引用

    以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空 间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。

    2. 软引用

    如果一个对象只具有软引用,那就类似于可有可无的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。

    软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA虚拟机就会把这个软引用加入到与之关联的引用队列中。.

    3. 弱引用

    如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。

    弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。

    4. 虚引用

    "虚引用"顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。

    虚引用主要用来跟踪对象被垃圾回收的活动。

    虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃 圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是 否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

    特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。

    更多可见:Java SE 基础 (2)

    九、如何判断一个常量是废弃常量

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

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

    十、如何判断一个类是无用类

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

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

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

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

    作者:意无尽 公众号:意无尽 关于作者:本人目前传统专业,现自学 Java,后续会有向大数据方向转型。希望自己能一步一个脚印的走下去,以此博客来见证我技术的成长轨迹!
  • 相关阅读:
    vue-router路由器的使用
    组件间数据传递
    引用模块和动态组件
    vue自定义全局和局部指令
    vue实例的属性和方法
    vue生命周期以及vue的计算属性
    vue 发送ajax请求
    安装vue-cli脚手架
    vue指令详解
    scrapy-redis组件的使用
  • 原文地址:https://www.cnblogs.com/reformdai/p/11135088.html
Copyright © 2020-2023  润新知