1.JVM
简介
JVM
是
Java Virtual Machine
的简称,意为
Java
虚拟机。
虚拟机是指通过软件模拟的具有完整硬件功能的、运行在一个完全隔离的环境中的完整计算机系统。
常见的虚拟机:
JVM
、
VMwave
、
Virtual Box
。
JVM
和其他两个虚拟机的区别:
1. VMwave
与
VirtualBox
是通过软件模拟物理
CPU
的指令集,物理系统中会有很多的寄存器;
2. JVM
则是通过软件模拟
Java
字节码的指令集,
JVM
中只是主要保留了
PC
寄存器,其他的寄存器都进 行了裁剪。
JVM
是一台被定制过的现实当中不存在的计算机。
2. JVM
运行流程
JVM
是
Java
运行的基础,也是实现一次编译到处执行的关键,那么
JVM
是如何执行的呢?
JVM
执行流程
程序在执行之前先要把
java
代码转换成字节码(
class
文件),
JVM
首先需要把字节码通过一定的方式 ,
类加载器(
ClassLoader
)
把文件加载到内存中
运行时数据区(
Runtime Data Area
)
,而字节码 文件是 JVM
的一套指令集规范,并不能直接交个底层操作系统去执行,因此需要特定的命令解析器
执
行引擎(
Execution Engine
)
将字节码翻译成底层系统指令再交由
CPU
去执行,而这个过程中需要调 用其他语言的接口 本地库接口(
Native Interface
)
来实现整个程序的功能,这就是这
4
个主要组成部分的职责与功能。
总结来看,
JVM
主要通过分为以下
4
个部分,来执行
Java
程序的,它们分别是:
1.
类加载器(
ClassLoader
)
2.
运行时数据区(
Runtime Data Area
)
3.
执行引擎(
Execution Engine
)
4.
本地库接口(
Native Interface
)
3. JVM
运行时数据区
JVM
运行时数据区域也叫内存布局,但需要注意的是它和
Java
内存模型(
(Java Memory Model
,简称 JMM)完全不同,属于完全不同的两个概念,它由以下
5
大部分组成:
3.1
堆(线程共享)
堆的作用:程序中创建的所有对象都在保存在堆中。
堆里面分为两个区域:新生代和老生代,新生代放新建的对象,当经过一定
GC
次数之后还存活的对象 会放入老生代。新生代还有 3
个区域:一个
Endn +
两个
Survivor
(
S0/S1
)。
垃圾回收的时候会将
Endn
中存活的对象放到一个未使用的
Survivor
中,并把当前的
Endn
和正在使用 的 Survivor
掉
3.2 Java
虚拟机栈(线程私有)
Java
虚拟机栈的作用:
Java
虚拟机栈的生命周期和线程相同,
Java
虚拟机栈描述的是
Java
方法执行的 内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame
)用于存储局部变量表、操作数 栈、动态链接、方法出口等信息。咱们常说的堆内存、栈内存中,栈内存指的就是虚拟机栈。
Java
虚拟机栈中包含了以下
4
部分:
1.
局部变量表: 存放了编译器可知的各种基本数据类型
(8
大基本数据类型
)
、对象引用。局部变量表
所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变
量空间是完全确定的,在执行期间不会改变局部变量表大小。简单来说就是存放方法参数和局部变
量。
2.
操作栈:每个方法会生成一个先进后出的操作栈。
3.
动态链接:指向运行时常量池的方法引用。
4.
方法返回地址:
PC
寄存器的地址。
什么是线程私有
?
由于
JVM
的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现,因此在任何一个确定的时
刻,一个处理器
(
多核处理器则指的是一个内核
)
都只会执行一条线程中的指令。因此为了切换线程后能
恢复到正确的执行位置,每条线程都需要独立的程序计数器,各条线程之间计数器互不影响,独立存
储。我们就把类似这类区域称之为
"
线程私有
"
的内存
3.3
本地方法栈(线程私有)
本地方法栈和虚拟机栈类似,只不过
Java
虚拟机栈是给
JVM
使用的,而本地方法栈是给本地方法使用的。
3.4
程序计数器(线程私有)
程序计数器的作用:用来记录当前线程执行的行号的。
程序计数器是一块比较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器。
如果当前线程正在执行的是一个
Java
方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;
如果正在执行的是一个
Native
方法,这个计数器值为空。
程序计数器内存区域是唯一一个在
JVM
规范中没有规定任何
OOM
情况的区域!
3.5
方法区(线程共享)
方法区的作用:用来存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据的。
在《
Java
虚拟机规范中》把此区域称之为
“
方法区
”
,而在
HotSpot
虚拟机的实现中,在
JDK 7
时此区域 叫做永久代(PermGen
),
JDK 8
中叫做元空间(
Metaspace
)。
PS
:永久代(
PermGen
)和元空间(
Metaspace
)是
HotSpot
中对《
Java
虚拟机规范》中方法
区的实现,它们三者之间的关系就好比,对于一辆汽车来说它定义了一个部分叫做
“
动能提供装
置
”
,但对于不同的汽车有不同的实现技术,比如对于燃油车来说,它的
“
动能提供装置
”
的实现技
术就是汽油发动机(简称发动机),而对于电动汽车来说,它的
“
动能提供装置
”
的实现就是电动
发动机(简称电机),发动机和电机就相当于永久代和元空间一样,它是对于
“
制动器
”
也就是方
法区定义的实现。
JDK 1.8
元空间的变化
1.
对于
HotSpot
来说,
JDK 8
元空间的内存属于本地内存,这样元空间的大小就不在受
JVM
最大内
存的参数影响了,而是与本地内存的大小有关。
2. JDK 8
中将字符串常量池移动到了堆中。
运行时常量池
运行时常量池是方法区的一部分,存放字面量与符号引用。
字面量
:
字符串
(JDK 8
移动到堆中
)
、
final
常量、基本数据类型的值。
符号引用
:
类和结构的完全限定名、字段的名称和描述符、方法的名称和描述符。
4.JVM
类加载
① 类加载过程
1.
加载
2. 连接(a.验证 b.准备)
3.
解析
4.
初始化
下面我们分别来看每个步骤的具体执行内容。
1)
加载
“
加载
”
(
Loading
)阶段是整个
“
类加载
”
(
Class Loading
)过程中的一个阶段,它和类加载
Class
Loading
是不同的,一个是加载
Loading
另一个是类加载
Class Loading
,所以不要把二者搞混了。 在加载 Loading
阶段,
Java
虚拟机需要完成以下三件事情:
1
)通过一个类的全限定名来获取定义此类的二进制字节流。
2
)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3
)在内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口。
2)
验证
验证是连接阶段的第一步,这一阶段的目的是确保
Class
文件的字节 流中包含的信息符合《
Java
虚拟机
规范》的全部约束要求,保证这些信 息被当作代码运行后不会危害虚拟机自身的安全。
验证选项:
文件格式验证
字节码验证
符号引用验证
...
3)
准备
准备阶段是正式为类中定义的变量(即静态变量,被
static
修饰的变量)分配内存并设置类变量初始值 的阶段。
比如此时有这样一行代码:
public static int value = 123;
它是初始化
value
的
int
值为
0
,而非
123
。
4)
解析
解析阶段是
Java
虚拟机将常量池内的符号引用替换为直接引用的过程,也就是初始化常量的过程。
5)
初始化
初始化阶段,
Java
虚拟机真正开始执行类中编写的
Java
程序代码,将主导权移交给应用程序。初始化阶段就是执行类构造器方法的过程。
② 双亲委派模型
什么是双亲委派模型?
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父 类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最 终都应该传送到最顶层的启 动类加载器中,只有当父加载器反馈自己无 法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
双亲委派模型的优点
1.
避免重复加载类:比如
A
类和
B
类都有一个父类
C
类,那么当
A
启动时就会将
C
类加载起来,那 么在 B
类进行加载时就不需要在重复加载
C
类了。
2.
安全性:使用双亲委派模型也可以保证了
Java
的核心
API
不被篡改,如果没有使用双亲委派模
型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为
java.lang.Object
类的话,那么程序运行的时候,系统就会出现多个不同的
Object
类,而有些
Object
类又是用户
自己提供的因此安全性就不能得到保证了。
5.
垃圾回收相关
上面讲了
Java
运行时内存的各个区域。对于程序计数器、虚拟机栈、本地方法栈这三部分区域而言,其生命周期与相关线程有关,随线程而生,随线程而灭。并且这三个区域的内存分配与回收具有确定性, 因为当方法结束或者线程结束时,内存就自然跟着线程回收了。因此我们本节课所讲的有关内存分配和回收关注的为Java
堆与方法区这两个区域。
Java
堆中存放着几乎所有的对象实例,垃圾回收器在对堆进行垃圾回收前,首先要判断这些对象哪些还 存活,哪些已经"
死去
"
。判断对象是否已
"
死
"
有如下几种算法
内存
VS
对象
在
Java
中,所有的对象都是要存在内存中的(也可以说内存中存储的是一个个对象),因此我们将内存回收,也可以叫做死亡对象的回收。
① 死亡对象的判断算法
a)
引用计数算法
引用计数描述的算法为
:
给对象增加一个引用计数器,每当有一个地方引用它时,计数器就
+1
;当引用失效时,计数器就
-1
;任
何时刻计数器为
0
的对象就是不能再被使用的,即对象已
"
死
"
。
引用计数法实现简单,判定效率也比较高,在大部分情况下都是一个不错的算法。比如
Python
语言就采用引用计数法进行内存管理。
但是,在主流的
JVM
中没有选用引用计数法来管理内存,最主要的原因就是引用计数法无法解决对象的
循环引用问题
2. 可达性分析算法
通过⼀系列称为"GC Roots"的对象作为起始点,从这些节点开始
向下搜索,搜索⾛过的路径称之为"引⽤链" ,当⼀个对象到GC
Roots没有任何的引⽤链相连时(从GC Roots到这个对象不可达)
时,证明此对象是不可⽤的。以下图为例:
对象Object5-Object7之间虽然彼此还有关联,但是它们到GC
Roots是不可达的,因此他们会被判定为可回收对象。
在Java语⾔中,可作为GC Roots的对象包含下⾯⼏种:
1.
虚拟机栈(栈帧中的本地变量表)中引⽤的对象;
2.
⽅法区中类静态属性引⽤的对象;
3.
⽅法区中常量引⽤的对象;
4.
本地⽅法栈中 JNI(Native⽅法)引⽤的对象。
》从上⾯我们可以看出“引⽤”的功能,除了最早我们使⽤它(引
⽤)来查找对象,现在我们还可以使⽤“引⽤”来判断死亡对象
了。所以在 JDK1.2 时,Java 对引⽤的概念做了扩充,将引⽤分
为强引⽤(Strong Reference)、软引⽤(Soft Reference)、弱引⽤
(Weak Reference)和虚引⽤(Phantom Reference)四种,这四种
引⽤的强度依次递减。
1.
强引⽤ : 强引⽤指的是在程序代码之中普遍存在的,类似
于"Object obj = new Object()"这类的引⽤,只要强引⽤还存
在,垃圾回收器永远不会回收掉被引⽤的对象实例。
2.
软引⽤ : 软引⽤是⽤来描述⼀些还有⽤但是不是必须的对象。
对于软引⽤关联着的对象,在系统将要发⽣内存溢出之前,会
把这些对象列⼊回收范围之中进⾏第⼆次回收。如果这次回收
还是没有⾜够的内存,才会抛出内存溢出异常。在JDK1.2之
后,提供了SoftReference类来实现软引⽤。
3.
弱引⽤ : 弱引⽤也是⽤来描述⾮必需对象的。但是它的强度要
弱于软引⽤。被弱引⽤关联的对象只能⽣存到下⼀次垃圾回收
发⽣之前。当垃圾回收器开始进⾏⼯作时,⽆论当前内容是否
够⽤,都会回收掉只被弱引⽤关联的对象。在JDK1.2之后提供
了WeakReference类来实现弱引⽤
4.
虚引⽤ : 虚引⽤也被称为幽灵引⽤或者幻影引⽤,它是最弱的
⼀种引⽤关系。⼀个对象是否有虚引⽤的存在,完全不会对其
⽣存时间构成影响,也⽆法通过虚引⽤来取得⼀个对象实例。
为⼀个对象设置虚引⽤的唯⼀⽬的就是能在这个对象被收集器
回收时收到⼀个系统通知。在JDK1.2之后,提供了
PhantomReference类来实现虚引⽤。
"标记-清除"算法的不⾜主要有两个 :
1.
效率问题 : 标记和清除这两个过程的效率都不⾼
2.
空间问题 : 标记清除后会产⽣⼤量不连续的内存碎⽚,空间碎
⽚太多可能会导致以后在程序运⾏中需要分配较⼤对象时,⽆
法找到⾜够连续内存⽽不得不提前触发另⼀次垃圾收集。
b.复制算法
它将可⽤内存按容量划分为⼤⼩相等的两块,每次只使⽤其中的
⼀块。当这块内存需要进⾏垃圾回收时,会将此区域还存活着的
对象复制到另⼀块上⾯,然后再把已经使⽤过的内存区域⼀次清
理掉。这样做的好处是每次都是对整个半区进⾏内存回收,内存
分配时也就不需要考虑内存碎⽚等复杂情况,只需要移动堆顶指
针,按顺序分配即可。此算法实现简单,运⾏⾼效
c.
标记-整理算法
复制收集算法在对象存活率较⾼时会进⾏⽐较多的复制操作,效
率会变低。因此在⽼年代⼀般不能使⽤复制算法。
针对⽼年代的特点,提出了⼀种称之为"标记-整理算法" 。标记过
程仍与"标记-清除"过程⼀致,但后续步骤不是直接对可回收对象
进⾏清理,⽽是让所有存活对象都向⼀端移动,然后直接清理掉
端边界以外的内存。