JVM常见知识点图解+文字

        JVM是我们每个java开发都绕不开的话题,java程序就是要通过jvm才可以实现跨平台的运行,今天就来用图片+文字的方式来梳理一下我们java程序在jvm虚拟机中的状态。

        这里先奉上JVM的结构图一张(网上找的,jvm结构图网上有很多,这里就不再手画了)

JVM常见知识点图解+文字_第1张图片

        这张图片可以说是很详细了,就基于这张图片进行展开。

        可以看到在我们编写的程序进入内存前,有着三个步骤,先来看看这三个步骤是什么。

JVM常见知识点图解+文字_第2张图片        

         首先程序猿用编译器或者记事本写出来的,就是HelloWorld.java这个文件,这个文件经过编译器编译或者执行javac可以获得一个HelloWord.class的字节码文件。

        之后在加载这个类的时候,需要用到一个叫做类装载器的类,就是图中的子系统。没错,这个类装载子系统也是一个类,并且都继承了ClassLoader这个抽象类。我们的.class 字节码文件就是通过这个类加载器装载到内存中的。

类加载器的双亲委派机制

        说到类加载器,不得不提到的就是它的双亲委派机制。什么是双亲委派机制?那就要先看看类加载器的种类

        BootstrapClassLoader(启动类加载器)

        ExtClassLoader (标准扩展类加载器)

        AppClassLoader(系统类加载器)

        CustomClassLoader(用户自定义类加载器)

        启动类加载器是用来加载Java核心类库中的类,是个用c++编写的类加载器

        标准扩展类加载器是用来加载扩展库,开发者可以直接使用标准扩展类加载器,是用java编写的

        系统类加载器是用来加载程序所在目录的类的类加载器,也是用java编写的。

        还有自定义加载器,可以用来加载指定路径的class文件

        了解了类加载器,再来说说是双亲委派机制。说白了,就是一个类加载器接收到加载一个类的请求,会先去调用更高一级的类加载器,以此类推直到最高级的类加载器(启动类加载器),当最高级的类加载器搞不定的时候,再将这个加载任务丢给低一级的类加载器,依次继续往下传。

        类加载器的逻辑其实很简单,但是这里有个坑要注意下,看这个关系,第一个想到的肯定是,低级类加载器继承高级类加载器,其实不是, 他们都是并列关系,是个if和else的关系,这里打一段伪代码加深理解

if(启动类加载器能搞定){
    启动类加载器搞定
}else if(启动类加载器能搞不定,标准扩展类加载器能搞定){
    标准扩展类加载器搞定
}else if(上面大哥二哥都搞不定){
    系统类加载器来搞定
}

沙箱安全机制

        说到双亲委派机制,又有多少人记得沙箱安全机制的。在这里提一嘴,万一面试官觉得双亲委派机制问烂了,突发奇想问问沙箱安全机制,不能一句话都说不上是不是。

        沙箱安全机制这个概念其实到现在已经被淡化了,沙箱机制就是将 Java 代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离。不同级别的沙箱对这些资源访问的限制也可以不一样。在后来的jdk版本里,沙箱已经被域取代。不过两者说白了都是为了限制远端代码的访问权限,来防止对本地的系统造成破坏。

        到这里为止,我们的字节码文件在被加载到内存中之前的步骤已经差不多完了,那接下来要看看类在内存中究竟是如何被加载的了

方法区(永久代)

        可以看到图中类加载器的箭头指向的是一个叫方法区的地方。那就先来看看这个方法区的图。

        JVM常见知识点图解+文字_第3张图片

        可以看到这个方法区的图中,有着类信息,常量,静态变量和即时编译器编译后的代码。      

类信息

        类信息,就是指这个类的基本信息,包括类的名称(完整的有效名称,包名.类名),类的访问修饰符(如public,final等),这个类型直接父类的完整有效名(除非这个类型是interface或是
java.lang.Object,两种情况下都没有父类),这个类型直接接口的一个有序列表

常量

        再来看常量,关于常量这一块其实网上是众说纷纭,一不小心自己都容易被套进去。这里在别的大佬的博客找了个比较靠谱的。

        全局字符串池(string pool也有叫做string literal pool)

        全局字符串池里的内容是在类加载完成,经过验证,准备阶段之后在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到 string pool 中。

        大概的意思就是说字符串的对象其实还是存储在堆中的,只是字符串对象的引用值存在了字符串常量值中。

        class文件常量池(class constant pool)

        class 文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种常量(final修饰)和符号引用(类和接口的全限定名,字段的名称和描述符,方法的名称和描述符)。

        运行时常量池(runtime constant pool)

        jvm 在执行某个类的时候,必须经过加载、连接、初始化,而连接又包括验证、准备、解析三个阶段。而当类加载到内存中后,jvm 就会将class常量池中的内容存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。

静态变量

        然后就是静态变量,静态变量就是指在我们类中,用static修饰符修饰的变量。

即时编译器编译后的代码

        还有就是即时编译器编译后的代码。这个也有叫做热点代码的,就是虚拟机发现某个方法或是代码块执行的特别频繁,会将这个代码块进行预编译,存在方法区。

其他信息

        其实除了图上这些意外,还有其他信息也有存在方法区的,比如,指向类加载器的引用,指向对象的引用(实例化的对象是被存在堆中的,这个等下讲),这些都是存在方法区。

方法区触发的GC

        我们所说的GC,就是堆内存的GC,大多数oom的bug也是由于堆内存溢出导致的,但是其实方法区也是有可能触发GC的。都知道方法区被称为永久代,那么既然full GC的时候会对永久代进行GC,可想而知,永久代空间不足时,也会触发full GC。而且永久代了老年代的空间,只要有一个被占满,就会触发full GC

从方法区到元空间

        其实在jdk1.8之后,就没有方法区这个概念了,这是存在演变过程的

        在jdk1.6时,方法区的模型正如上图,方法区的内存实际上是和堆内存在一起的,但是又是相互独立的两块区域,如果有心人用去查看会发现,jvm大小=堆大小+栈大小,这个方法区只是个概念存在,而不是实际的物理存在,本质上方法区还只是堆的其中一块区域

        到了jdk1.7后,做了一个变动,就是运行时常量池从方法区移除了,直接移动到了堆中。注意这个堆是概念堆,因为原本方法区和堆内存就是公用一片物理的内存空间的。

        再到了jdk1.8后,方法区做了一个决定,那就是将脱离堆内存,从此这个被移除的方法区就拥有了自己的内存空间,还改了个名字叫元空间(脱离父子关系后改了姓名??)

本地方法栈

        看完了元空间,就轮到重头戏堆栈了,再看堆栈之前,可以看到有个叫本地方法栈的空间,这么本地方法栈是什么呢?

        其实我们JAVA代码是在虚拟机上执行的,但是却无法去操作直接通过java去操作硬件,这时候就需要借助C++去完成,而本地方法栈存的就是C++的方法。

        这也是有故事背景的,在java刚出生的时候,还是C语言和C++的天下,那时候的汇编语言想要立足,就必须要支持C和C++,而且JAVA诞生的初衷就是为了简化C语言中繁琐的指针以及多继承,所以当时的java又叫做C++ --,就是指这是C++语言的简化版。而正是在这种大环境下,基于jvm虚拟机运行的java语言和操作计算机硬件的C++语言集合在了一起。现在的代码中的native修饰词就是用来调用C++方法的。比如线程Thread类,就有很多类似public static native void yield();的方法。

JAVA虚拟机栈

        看完了本地方法栈,再看java虚拟机栈。

JVM常见知识点图解+文字_第4张图片

        要看栈就要先知道栈的存储数据方式,在栈内存数据时,底部时无法获取数据的,所以永远都是在栈顶部的数据先被取出,之后再逐级向下获取,栈底的数据最后才会被取出,而往栈里丢数据的过程被很形象得叫做“压栈”,取数据被叫做“弹出”。

         这里可以看到本地方法栈内存被划分成和很多块,每一块都有局部变量表,操作数栈,动态链接和方法出口,这里就要引入一个概念,我们俗称的堆栈是不是1对1的关系?JVM内是不是只有一个堆和一个栈呢?

        其实这个问题可以说是,也可以说不是,因为jvm确实只划分了一块堆空间和一块栈空间,所以说JVM只有一个堆和一个栈时没错的,但是在栈空间中,其实还可以分为很多小块空间,每一块空间都是一个线程栈,也就是说当一个多线程程序在jvm中运行时,会将栈空间划分为很多分,并且每个线程都拥有一份独立的属于自己的栈空间。这就是为什么JVM只有一个堆和一个栈这个问题可以是对,也可以是错(如果面试官问这个问题,你只说了对,却没给他解释清楚的话可能会让你出门右转= =)。

       如果要想方便记忆,可以把栈空空间理解为一打啤酒,而每一个线程就相当于一瓶啤酒,插在啤酒框内,这整个啤酒框加啤酒,就是我们的栈空间。

         上面的截图,其实就是一个栈空间中,某一个线程栈的模型,然后我们看到的,分成一块块区域的,有个专业名词叫栈帧,意思就是每个执行的方法,都会在当前栈中占用一小块空间(实际物理空间)。而压栈实际上也就是往栈中压栈帧。

        而栈中最先执行的方法会被压在最底下,从图中可以看出来,是main方法被压在了当前线程栈的最底下,之后才是calculate方法,当calculate方法执行完成后,calculate方法从当前线程栈中弹出,然后main方法才继续执行。即只有在栈最顶端的方法会被执行。

        那再来看看一个栈帧中有什么呢?局部变量表,操作数栈,动态链接,还有方法出口。

局部变量表                

首先是局部变量表,局部变量就是指方法内的变量,如图中代码的a,b,c都是局部变量,因为他们都是在calclate方法内部,而main方法中定义的helloWorld和result两个变量也是局部变量,但是有一点不同的是,像a,b,c,result这四个变量都是基本数据类型,所以被直接存在了栈帧内,而helloWorld是一个被实例化出来的对象,他的实例是被存在堆中的(一会儿讲)。

操作数栈

        然后就是操作数栈,对于操作数栈这一块理解不是很深,只是知道几个特点。一个就是操作数栈中是用数组的形式来实现的,然后就是操作数栈是作为方法计算时的临时储存空间来使用的。每一个操作数栈都会拥有一个明确的栈深度用于存储数据,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为max_stack的值。

 动态链接

        动态链接实际上可以当作时一个引用,这个引用指向的是当前类在方法区中的运行时常量池。的符号引用。这里有必要说一下符号引用的概念。运行时常量池分为两种,一种是符号引用,一种是静态常量。比如有个变量String a = "hello world";那么这里的hello world是静态常量,但是这个a这个变量就是指符号引用了,而我们动态链接指向的正是这个a。

方法出口

        这个方法出口之前百度的时候,有个大佬给出的解释是:记录当本方法执行完之后,回到主方法时改从哪一行执行。

        但是我觉得应该理解为——记录下当前方法结束后,回到调用当前方法的位置继续往下执行。

        因为我们是在一个栈空间中压方法,所以当前方法结束后,接下来要执行的应该是调用当前方法的方法,而不是主方法。

程序计数器

        可以看到在图中栈帧顶部有一个程序计数器,这个程序计数器又是什么呢?顾名思义,程序计数器当然是要计数用的啦(废话)。那么计数到底是为什么东西计数呢?这个计数器实际上指向的是当前正在运行的字节码中的行号。

        程序计数器还拥有以下几点特点:

1.线程隔离性,每个线程工作时都有属于自己的独立计数器。

2.执行java方法时,程序计数器是有值的,而执行本地方法栈中的c++方法时,是无值的。

3.程序计数器占用内存很小,在进行JVM内存计算时,可以忽略不计。

4.程序计数器,是唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError的区域。

        看到这里,差不多可以算是把栈这一块的基础知识都看完了,个人认为栈空间,线程栈,栈帧的概念是这一块的重点。可以脑补一个适合自己的模型来辅助自己进行记忆。

堆(-Xmx堆内存最大值,-Xms堆内存最小值)

        每天写这么多BUG,总有时候会把自己坑进去,当你看到控制台或是日志文件给你提示的OutOfMemoryError: Java heap space(后面简称oom)的时候是不是一个头两个大。目前本人在工作中遇到的oom异常大多数情况下会出现在堆中(不是说栈不会溢出,栈溢出报的是StackOverflowError,而元空间溢出报的是OutOfMemoryError:MetaSpace)。那么这个堆里到底是放着什么东西呢?为什么会溢出呢?

        其实上文已经提过,堆内存放的,是类的对象实例,也就是俗称的new出来的,或者是用反射创建的对象。但是如果一直创建对象,又不去回收它,那么总有一天,我们jvm的内存会被对象给撑爆了,于是GC垃圾回收器由此诞生。

堆中的空间分布

        先来看看图中堆内存的分布

JVM常见知识点图解+文字_第5张图片

         可以看到里面的次,有叫新生代和老年代的,其实在jdk1.8之前,老年代之后还有个叫终生代的,名义上也和堆空间相连,却又是独立的一快空间。这个上文方法区时已经做过解释。

        那么先来看下新生代,很大一块区域时被Eden(伊甸区)占领的,而还有两块区域叫from和to(有些简称S1和S2的),然后在新生代外就是老年代。至于为什么要如此划分,那就要说说java的垃圾回收机制(GC)了

GC垃圾回收器

        说垃圾回收器之前,要先说一说垃圾回收算法,我们对象的实例在堆中是个大杂烩,有用的无用的对象并不是有序排列的,因此也就衍生出了垃圾回收算法。

对象的四种引用

        但是在看算法之前,应该先看看还有个叫做引用的概念,引用其实很简单,就是之前说的,在方法区中,每个类会保存指向这个类的实例化对象的引用。而引用又分为4种,分别是强引用,软引用,弱引用和虚引用。

强引用

        强引用是我们看上去最直观的引用,只要强引用还存在,无论如何GC也不会去回收这个对象,甚至宁可抛出oom。举个例子,以下代码就是强引用。

Obj obj = new Obj();

软引用

        软引用是用来描述一些有用但并不是必需的对象,在内存足够是,该对象不会被回收,但是当内存不足即将OOM时,该对象会被GC回收,软引用的创建方式如下:

Obj obj = new Obj();

SoftReference(Obj) softReference = new SoftReference(obj);

当内存不足时,会将这个obj置为null,之后再被GC回收。

弱引用

        弱引用也是用来描述一些有用但是非必须的对象的,而且弱引用的生命周期比软引用更短,当触发GC时,弱引用的对象是必定被回收的。下面也举一个弱引用的例子

Obj obj = new Obj();

WeakReference(Obj) weakReference= new WeakReference(obj);

虚引用

        虚引用完全被不会影响一个对象是否会被回收,但是有着虚引用的对象会在被回收之前发出一个通知,表示对象将被回收。虚引用的例子也举一个。

Obj obj = new Obj();

PhantomReference(Obj) phantomReference= new PhantomReference(obj);

标记清除法

       四种引用类型看完了,那么回到正题,到底有哪几种垃圾回收算法呢?

        标记清除法顾名思义,就是对堆中的无用对象进行标记,并将标记的对象清楚,那么在这个过程中,是如何确定这个对象是否为无用对象呢?这就要用到一个叫根可达算法的算法。什么是根可达算法呢?这个就更好理解了,我们都知道在方法区中,每个类都会有保存指向该类的对象的引用,而当一个实例化的对象已经没有被任何引用指向了,那就说明这个对象就要被回收了。

        事实上在标记清除法标记之前,jvm会对堆空间进行一次扫描,标记上需要回收的对象,而在回收之前,又会进行一次扫描,只有二次扫描结果一致时,该对象才会被清楚。

        标记清除法可以说是最简单粗暴的一种垃圾回收算法了,但是他还是有着不少缺点,在刚开始时就说了,对象实例在堆中是杂乱无序的,那么如果直接用标记清除法会导致堆空间中出现很多内存碎片,影响内存的利用率(我的邻居都被清除出去了,但是我却留着,那就成了钉子户,就会严重影响土地利用率)

标记压缩法

        之前说了,既然标记清除法有这么大的缺点,那自然有人会对这种方法进行改进,也就有了之后的标记压缩法。

        标记压缩法就是在标记清除法的基础上,将需要清除的标记清楚后,重新规划内存空间,将剩下的对象重新整齐排列在内存中。

        标记压缩法的确可以增加内存空间的利用率,但是随之而来的问题是重新排序对象的效率会更低。

复制法

        复制法,顾名思义,就是将一块内存区域的对象复制到另一块区域,那么这块原内存区域的地址自然是连续可用的了(图中from区和to区)

GC的种类

   GC也分为好多种类,他们回收的内存区域也不同,这里直接网上复制了一份

        Partial GC:并不收集整个GC堆的模式

                Young GC:只收集young gen的GC

                Old GC:只收集old gen的GC。只有CMS的concurrent collection是这个模式

                Mixed GC:收集整个young gen以及部分old gen的GC。只有G1有这个模式

        Full GC:收集整个堆,包括年轻代、老年代、终生代(如果存在的话)等所有部分的模式。

年轻代

        年轻代指的是图中的伊甸区,from区和to区,可以通过指令来设置年轻代和整个堆内存的占比

(-Xmn,默认是1/3的年轻代,2/3的老年代),当然,我们公司用的是G1垃圾回收器,会自动调整两者大小,一般不需要再进行设置。

伊甸区

        看完了垃圾回收算法,再来看下图中最左边的那块伊甸区。伊甸区就是对象刚出生时所在的区域,这块区域通常占有老年代的80%,而在这块区域的对象被回收的概率非常高,只有很少一部分才能进到from和to区域。当对象在伊甸区经历Young GC时,若能侥幸不被清除,就会进入from区。我们可以用参数来设置伊甸区的大小(-XX:SurvivorRatio,默认值是8,意思是伊甸区占8/10)

from和to

        from和to也有被称为Survivor1(S1)区和Survivor2(S2)区,在S1和S2区的对象同样会经历GC,同时两者的存储情况是会互换的,例如首次Young GC时,伊甸区的幸存对象进入了S1区,之后再新增对象,就是伊甸区和S1区内有对象了,再来到第二次Young GC,S1区和伊甸区的对象会经历一轮回收,然后都被放到S2区。

        设立俩个Survivor区是和垃圾回收算法有关的,当经历Young GC时,伊甸区和S1区的对象先被标记清除,之后又被复制法复制到S2区,此时的S2区就是一个被整理好的存储所有幸存对象了空间了。

老年代

        当对象经历过多次from和to的折腾后(身经百战。。。),终于步入老年,拿着养老金安心过日子了,这就是我们的老年代,这个次数可以通过PretenureSizeThreshold:参数设置(G1默认是15次)。

        当对象到达老年代时,基本不用害怕被回收了,因为普通的youngGC已经触及不到老年代了,但是并不代表老年代的对象就不会被回收,以G1为例,当老年代的空间占用到达97%时,会触发FullGC,回收堆内存中所有的对象(都步入老年了,要是摸鱼还是会被抓。。。)。其实在这之前,老年代的占比如果到达整个堆内存的45%,还会触发Mixed GC,回收部分老年代的区域。

G1垃圾回收器

        其实看到这里,JVM模型图大部分区域都已经解释过了。虽然比较浅显,但是如果深究的话,很多知识点都能单独写一篇文章。之前公司用的都是G1垃圾回收器,之后如果有空的话会整理一篇文章发上链接。

        我知道我写的不是很好,本来这个文章是基于自己的基础做的一个笔记,不打算写这么多的,但是怎么说呢,不想别人看得云里雾里,就还是啰嗦了这么多,有些地方可能理解并不是很透彻,主要还是传递一个学习思路吧,尤其是之前就有基础和印象的。不要去看大篇幅的文章,容易把自己绕进去,不如找一张图片,照着图片区一个个理解。另外文中有错误的地方还请大佬们指出(轻喷。。)

你可能感兴趣的:(笔记,jvm)