type
status
date
slug
summary
tags
category
icon
password
😀
这里写文章的前言: 一个简单的开头,简述这篇文章讨论的问题、目标、人物、背景是什么?并简述你给出的答案。
可以说说你的故事:阻碍、努力、结果成果,意外与转折。
 

📝 主旨

notion image
 
notion image
 

📔 程序计数器

  • 唯一一个无OOM的地方,类比操作系统中的 PC
  • 记录正在执行的虚拟机字节码指令的行号(如果正在执行的是本地方法则为空)
  • CPU是轮转分片执行的, 为了防止CPU切换的时候返回忘记之前执行到哪一步,需要程序计数器来记录走到具体哪一行指令了(依赖指令前的序号定位)
  • class文件会看到一串增序的数字就是指令执行的顺序,这一串增序可能不是连续的,比如下面1-3之间的2消失了,因为机器本身要执行其他的操作,这部分操作是不希望你看到的,可能和安全有关系就没展示了.(计算机硬件封装的操作没必要全部给你展示)
 
为什么被设定为私有的
因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行,如果是共享的话,就混乱了不好辨别
notion image
 

📔 Java 虚拟机栈

栈是解决程序的运行问题,即程序如何执行,或者说程序如何处理数据 (顺序是: 先进后出)
  • 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程
  • 入栈表示被调用,出栈表示执行完毕或者返回异常
  • 当前CPU调度的那个线程叫做活动线程;一个栈帧对应一个方法,活动线程的虚拟机栈里最顶部的栈帧代表了当前正在执行的方法,而这个栈帧也被叫做 当前栈帧,(也就是正在执行的方法在栈顶)
  • 可以通过 Xss 这个虚拟机参数来指定一个程序的 Java 虚拟机栈内存大小java -Xss=512M HackTheJava
 
1. 当线程请求的栈深度超过最大值,会抛出 StackOverflowError 异常; 2. 栈进行动态扩展时如果无法申请到足够内存,会抛出 OutOfMemoryError 异常 3. 基本数据类型变量和对象的引用都是在栈分配的 4. 默认大小是1M
Java 只有值传递, 引用类型也是值传递,只不过值是引用地址
notion image

局部变量表

  • 存的是对象的引用, 或者基本数据类型
  • 一片连续的内存空间,用来存放方法参数,以及方法内定义的局部变量,存放着编译期间已知的数据类型(八大基本类型和对象引用(reference类型)
  • 它的最小的局部变量表空间单位为Slot,虚拟机没有指明Slot的大小,但在jvm中,long和double类型数据明确规定为64位,这两个类型占2个Slot,其它基本类型固定占用1个Slot
  • 局部变量表所需要的内存空间在编译期完成分配,当进入一个方法时,这个方法在栈中需要分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表大小
相当于OS 中的cache
class文件如下:
notion image
notion image

操作数栈

主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
  • 是执行引擎的工作区,(存储中间态的数据),执行引擎不保存数据,数据需要有地方放.就放操作数栈, 相当于OS 中的寄存器
  • 如下代码
    • 将1,2,10 压入操作数栈,执行引擎计算x+y,也就是(1+2)得到3,再取出10,与3计算得到30,将30压入操作数栈,随后取出 赋值给变量Z
notion image
 

栈顶缓存技术

我们的执行引擎执行命令,以及我们的局部变量表存储对象,都需要通过操作数栈来实现,只会和操作数栈交互明白了这点,就知道提升操作数栈的运行效率有多么的重要,因此引入了栈顶缓存技术
  1. 前面提过,基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数(也就是你会发现指令很多)和导致内存读/写次数多,效率不高。
  1. 由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率。(栈顶就是当前运行的方法)
  1. 寄存器的主要优点:指令更少,执行速度快,但是指令集(也就是指令种类)很多

动态链接

可以理解为直接引用,每个栈帧包含一个指向运行时常量池中该栈帧所属方法引用,持有这个引用是为了支持方法调用过程中的动态链接
为了支持方法的动态的调用过程(java是多态的,调用的时候可能才知道具体是设谁在调用,方法可能是重写的);
给到我们的方法一个确定的值,符号引用转变成直接引用
A-B B多态 C/D,加载过程是不知道B是调用C还是D的, 要实际调用的时候才知道
notion image
 

方法返回地址

方法退出后都返回到该方法被调用的位置。方法正常退出时,
调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。
而通过异常退出的,返回地址是要通过异常表来确定
本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去
  • 正常退出 - 程序计数器的行号
  • 异常退出 - 异常处理表 try catch

异常处理表

  • 反编译字节码文件,可得到 Exception table
  • from :字节码指令起始地址
  • to :字节码指令结束地址
  • target :出现异常跳转至地址为 11 的指令执行
  • type :捕获异常的类型
notion image
 

方法调用

Java 的方法的执行分为两个部分:
  • 方法调用:确定被调用的方法是哪一个;
  • 基于栈的解释执行:真正的执行方法的字节码。
 

调用解析

解析调用,也叫静态解析,就是 在类加载的解析阶段,就确定了方法的调用版本 。我们知道类加载的解析阶段会将一部分符号引用转化为直接引用,这一过程就叫做解析调用。因为是在程序真正运行前就确定了要调用哪一个方法,所以 解析调用能成立的前提就是:方法在程序真正运行前就有一个明确的调用版本了,并且这个调用版本不会在运行期发生改变。

分派调用

分派调用,也叫动态解析,我们先来介绍一下 Java 所具备的面向对象的 3 个基本特征:封装,继承,多态。
其中多态最基本的体现就是重载和重写了,重载和重写的一个重要特征就是方法名相同,其他各种不同:
  • 重载:发生在同一个类中,入参必须不同,返回类型、访问修饰符、抛出的异常都可以不同;
  • 重写:发生在子父类中,入参和返回类型必须相同,访问修饰符大于等于被重写的方法,不能抛出新的异常。
相同的方法名实际上给虚拟机的调用带来了困惑,因为虚拟机需要判断,它到底应该调用哪个方法,而这个过程会在分派调用中体现出来。其中:
  • 方法重载 —— 静态分派
  • 方法重写 —— 动态分派

静态分派(方法重载)

通俗的讲,静态分派就是通过方法的参数(类型 & 个数 & 顺序)这种静态的东西来判断到底调用哪个方法的过程。

动态分派(方法重写)

动态分派就是在运行时,根据实际类型确定方法执行版本的分派过程。
动态分派在虚拟机种执行的非常频繁,而且方法查找的过程要在类的方法元数据中搜索合适的目标,从性能上考虑,不太可能进行如此频繁的搜索,需要进行性能上的优化。
 
常用优化手段:在类的方法区中建立一个虚方法表
  • 虚方法表中存放着各个方法的实际入口地址,如果某个方法没有被子类方法重写,那子类方法表中该方法的入口地址 = 父类方法表中该方法的入口地址;
  • 使用这个方法表索引代替在元数据中查找;
  • 该方法表会在类加载的连接阶段初始化好。
通俗的讲,动态分派就是通过方法的接收者这种动态的东西来判断到底调用哪个方法的过程。

栈帧复用优化技术

如果存在一个方法调用另一个方法,局部变量表的参数会共用一部分,减少空间的使用,复用数据
notion image
 

本地方法栈

本地方法不是用 Java 实现,C++做的,本地方法栈就是给本地native方法使用的,没有程序计数器,因为执行引擎改不到(不是java写的方法),所以单独搞一片空间给native方法用

堆空间

堆解决的问题是数据存储的问题,即数据怎么放,数据放哪儿的问题。
  • 大部分对象实例都在 分配内存。
  • 是垃圾收集的主要区域,现代的垃圾收集器基本都是采用分代收集算法,该算法的思想是针对不同的对 象采取不同的垃圾回收算法,因此虚拟机把 Java 堆分成以下2块
  1. 新生代(Young Generation)
  1. 老年代(Old Generation)
 
当一个对象被创建时,它首先进入新生代,之后有可能被转移到老年代中。新生代存放着大量的生命很短的对象,因此新生代在三个区域中垃圾回收的频率最高。为了更高效地进行垃圾回收,把新生代继续划分成以下三个空间
  1. Eden 先分配到eden区
  1. Survivor 每次垃圾回收,会将eden区的存活对象放到S0中,
  1. To Survivor 每次垃圾回收,会将S0区的存活对象通过复制算法放到S1中,然后清空另外一个S区
 
可以通过 -Xms-Xmx 两个虚拟机参数来指定一个程序的 Java 堆内存大小,第一个参数设置初始值,第二个参 数设置最大值 ,一般设置最初始值和最大值相等, 这样JVM就不用多次更改大小消耗性能
java -Xms=1M -Xmx=2M
虚拟机分配的总内存是电脑内存的1/4,初始化的内存是电脑内存的1/64
 
notion image
 
🤖为什么要有2个S区,如果只有一个的话, 第二次清理之后,对象要么直接进入老年代, 要么不放入老年代,
  • 放入, 由于大部分对象是朝生夕死,直接进入老年代代价太大,
  • 不放入,S区就会有垃圾碎片的情况,两个S区就可以用复制算法,把对象复制copy整理到另一个S区
 
🤖为什么要分代
优化GC的性能,如果没有分代的话,所有的对象都在一起,在GC的时候,就会对整堆进行全局扫描,然后很多对象都是朝生夕死的;如果分代的话,就把这些对象聚集在一起,GC先回收这部分,就会节省很多空间

方法区

  • 用于存放已被加载的类信息(方法信息)、常量静态变量、即时编译器编译后的代码等数据 (CGLIB的class文件)
  • 和 Java 堆一样不需要连续的内存,并且可以动态扩展,动态扩展失败一样会抛出 OutOfMemoryError 异常。 对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现,HotSpot 虚拟机把它当 成永久代来进行垃圾回收
 

HotSpot方法区的变化

  • JDK1.6之前 用永久代(permanent generation) 静态变量存放在永久代上
  • JDK1.7 有永久代,但是已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中
  • JDK1.8之后 无永久代。类型信息、字段、方法、常量保存在本地内存的元空间,但是字符串常量池、静态变量仍在堆

常量池

静态常量池

加载的时候,把class里面的常量池加载进方法区 字面量(String a ="啊", "啊"就是字面量) 符号引用(方法A的名字)等信息,(编译的时候可能方法A还没被分配空间,只能先用符号引用代替,后面再根据符号引用去找真正的内存地址)

运行时常量池

直接引用(运行的时候才有直接引用)

字符串常量池

String 类的 intern()。这部分常量也会被放入运行时常量池。 字符串常量池的重心是要解决String是使用效率问题;因为String用的太多了
 

元空间

  • JDK1.8之后,将方法区的实现永久代,更改为元空间
  • 方法区存储的数据是类相关,静态变量,常量相关信息,设置大小比较难控制,因为多了会占空整个JVM其他区域的比重,少了又会报出OOM影响程序
  • JDK1.8之后,方法区改成元空间,元空间直接使用本地内存,(OS的内存),不占用JVM其他区域资源,但是OS内存不够的时候也会报OOM
  • 元空间使用的是本地内存,所以垃圾回收的效率也更快了,不需要再扫描元空间这一片区域.减少垃圾回收的范围
正常来说 256M就够了.参考编译后的target文件大小
 

各数据处于JVM内部的位置

notion image
 

一个对象在JVM分布的情况

notion image
 

对象的分配

notion image
当java虚拟机遇到了一条字节码new指令时,首先会去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载,解析和初始化.如果没有,就执行相应的类加载过程. 类加载检查通过后, 为对象分配空间的任务实际上便等同于把一块确定大小的内存快从java堆中划分出来. 1. 指针碰撞(Bump The Pointer): 假设java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离. 2. 空闲列表(Free List): 如果java堆内存并不是规整的,已使用内存和空闲内存相互交错在一起,那就没有办法简单的进行指针碰撞. 虚拟机需要维护一个列表,记录上那些内存块是可用的,在分配的时候,从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录. 当使用 serial,parNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单也高效
当使用CMS这种基于清除(sweep)算法的收集器时,理论上只能采用 并发分配: 对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发的情况下也是不安全的. 方案一: 对分配内存空间的动作进行同步处理,实际上虚拟机采用的CAS; 另外方案是: 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在java堆中预先分配一小快内存,成为本地线程分配缓冲(TLAB),那个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓冲区时才需要同步锁定. 虚拟机是否使用 TLAB,可以通过 -XX: +/-UserTLAB参数来设定

对象的访问方式

Object obj = new Object();
Object obj 这部分的语义将会反映到Java 栈的本地变量表中,作为一个reference 类型数据出现
new Object() 这部分的语义将会反映到Java 堆中,形成一块存储了Object 类型所有实例数据值
reference 类型在Java 虚拟机规范里面只规定了一个指向对象的引用,并没有定义这个引用应该通过哪种方式去定位,以及访问到Java 堆中的对象的具体位置,因此不同虚拟机实现的对象访问方式会有所不同,主流的访问方式有两种:使用句柄和直接指针

直接访问

指针-对象的引用地址 (Hotspot用的方式)
优点:更有效率,直接指向地址,访问速度更快
缺点:如果我的对象消失了,我需要对引用做修改

句柄访问

句柄-特殊的指针 , 由系统管理的标识,该标识可以重新被定位一个新的内存地址上
优点:使用句柄访问方式的最大好处就是reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference 本身不需要被修改
缺点是:效率低,要先通过句柄池,再找具体的地址
安全在哪:
句柄是指向内存池的一片区域
可以理解为指针的指针
那既然内存池的区域已经分配了,
那么里面是不是为空,这个指针都不可能是空
notion image
 

🤗 总结归纳

 

📎 参考文章

     
    💡
    有关文章的问题,欢迎您在底部评论区留言,一起交流~