一、前言
1、JVM简介
全称java virtual machine,即java虚拟机,可以将它比喻成一台虚拟的计算机,它内部也有各种指令---字节码指令集;并且其内部也会进行内存管理,主要有堆、栈、方法区等;它是定义了一套规范;我们狭义上指的虚拟机一般是HotSpot虚拟机,因为其应用最为广泛。
2、java从编译到执行
首先.java源文件经过编译变成.class字节码文件,然后class文件运行在jvm上,经过jvm执行变成机器码的过程,最终运行在计算机上。
3、jvm的跨平台与语言无关性
由于jvm可以运行在多种操作系统上,比如window、linux、macOS等,所以jvm具有跨平台的特性;
所有可以编译成.clss字节码的语言都可以运行在jvm上,因此jvm也具有语言无关性,比如java、kotlin、groovy都编译成class字节码;
4、JavaSE的体系架构
jvm
java虚拟机,java虚拟机的作用只是将我们编译后的字节码文件变成机器识别的代码,但它本身并不能产生代码,因此还需要开发人员来编写代码,此时就要使用到jre了;
jre
全称java runtime environment,即java运行时环境,里面除了包括jvm之外,还包括编写java代码时需要的基础类库,比如操作文件的类库、操作io的类库、操作网络的类库等;但我们编写代码还需要java语言本身以及一些开发工具,此时就要用到jdk了;
jdk
全称java development kit,即java开发工具包,除了包括jre之外,还包括java语言本身,以及开发中需要用到的各种开发工具,比如javac编译工具、javap反编译工具、打包代码jar工具等,共同构成了jdk;
因此jdk、jre、jvm的关系是:jdk包含jre,jre包含jvm。
二、JVM运行时数据区
JVM内存管理,也就是jvm运行时数据区的堆栈分析
1、简介
java相较于c++来说一个最大的优点就是其自动内存管理机制,不需要像c++一样手动管理内存;jvm运行时管理的内存区域一般称之为运行时数据区,其实就是将实际内存映射jvm中,方便进行内存的分配和管理;运行时数据区分为堆、方法区、虚拟机栈、本地方法栈、程序计数器,其中线程共享的区域是堆和方法区,其余是线程私有的区域。
2、程序计数器
程序计数器是一块很小的内存空间,用来记录当前线程执行的字节码的行号;由于cpu的时间片轮转机制,我们知道线程可能在执行过程中失去cpu的运行时间片,因此需要使用程序计数器来记录当前线程执行到的位置,方便线程恢复执行的时候知道从哪里继续执行。程序计数器是唯一一块不会发生OOM的内存区域。
3、虚拟机栈
栈是一种后进先出的数据结构,在jvm运行过程中主要用来存储当前线程运行方法所需的数据;在当前线程运行过程中,java方法被调用的时候就会创建一个栈帧并进入虚拟机栈,因此栈帧用来表示这个方法,当虚拟机栈中的栈帧全部出栈后,也就代表方法执行完了,即线程结束了。每个栈帧都会包含四个区域,局部变量表、操作数栈、动态链接、返回地址:
1)、局部变量表:顾名思义就是用来存放局部变量的表,它是一个32位的长度,主要用来存储java八大基本数据类型,如果是64位的类型就会占据两个格子;如果存储Object对象时,在局部变量表中存储的就是对象的地址引用;
2)、操作数栈:就是用来存放操作过程中的java数据类型的,在方法运行过程中操作数栈中的操作数不断进行入栈和出栈的操作;
3)、动态连接:主要反映java多态的特性,很多类在运行时才能确定具体的类型信息,因此调用超类中的方法也就只有在运行时才能确定具体运行的哪个类的方法;
4)、返回地址:正常返回的情况下会返回程序计数器中记录的地址;异常返回的情况下会根据异常处理表的信息来返回;
4、本地方法栈
本地方法栈是类似于虚拟机栈的结构,只不过是用来运行本地方法的,也就是native方法,本地方法一般使用c或c++编写的,虚拟机规范中对本地方法栈并没有强制规定,在HotSpot虚拟机中将虚拟机栈和本地方法栈合二为一了;
5、方法区
很多人会将永久代、元空间和方法区混淆,方法区是java虚拟机规范中定义的区域,而永久代是java7之前在HotSpot虚拟机中对方法区的一种实现方式,而在java8中摒弃了永久代的概念,取而代之的是元空间,并且元空间的存储位置也变成了堆外内存(直接内存);
方法区主要用来存储已被虚拟机加载的类信息,包括类信息、静态变量、常量、运行时常量池、字符串常量池等;
常量池主要用来存放编译期间生成的各种字面量和符号引用;字面量包括字符串、基本数据类型的常量,符号引用包括类的全限定名和描述、方法的名称和描述符、字段的名称和描述符;
6、堆
堆是jvm中最大的内存区域,几乎所有的对象都是在堆上创建的,而堆也是GC垃圾回收主要操作的区域;堆又分为新生代和老年代,新生代又分为Eden区、from区、to区;
那么对象到底是在堆上分配的还是在栈上分配的,分两种情况:
第一种:如果是普通对象,那么jvm首先在堆上创建对象,在其他地方使用的都是对象的地址引用,比如在虚拟机栈中的局部变量表中存储的引用;
第二种:如果是基本数据类型,又分为两种情况,当在方法体中创建的基本数据类型则直接在栈上分配,其他情况都是在堆上分配;
三、虚拟机优化技术
1、编译优化技术--方法内联
方法内联的实际操作就是在方法执行过程中直接将目标方法的代码复制到调用处,而避免了真实的方法调用,也就省去了方法栈帧的入栈出栈;
2、栈帧事件数据的共享
在一般的模型中不同的方法栈桢一般是独立存在的,但大部分JVM会进行一些优化,是两个栈帧出现一部分重叠;主要使用在方法中有参数传递的过程中,比如让下面栈帧的操作数和上面栈帧的局部变量表进行重叠,这样不但节约了空间,更重要的是参数调用的时候省去了复制操作,可以直接进行复用;
四、虚拟机中对象的创建过程
当JVM遇到一条new指令创建对象时会进行以下过程:
1、检查加载
虚拟机首先会检查类是否已经被加载到方法区中,如果没有加载,则首先会去执行类加载;
2、分配内存
第二步是在堆中给对象分配内存,主要通过指针碰撞和空闲列表两种方式:指针碰撞一般用于堆中对象划分规整的情况,用过的在一块区域,没用过的在另一块区域,通过移动指针来给对象分配内存;空闲列表是指有个列表维护着哪些区域已经使用、哪些区域还没使用,一般堆中经过垃圾回收且没有进行整理的话就会变成使用区域和未使用区域随机排列了;
在堆中分配内存还需要考虑并发安全问题:一般使用两种方法,第一种是CAS加失败重试;第二种是本地线程分配缓冲,可类比ThreadLocal理解;
3、内存空间初始化
虚拟机将分配到的内存空间都初始化为零值,比如int置为0,boolean置为false,String置为null等,所以这也解释了实例字段即使我们不赋初值也可以使用,其实使用的就是初始值;
4、设置
接下来虚拟机设置对象的对象头,对象头主要包括对象属于哪个类的实例、类的元数据信息、对象的哈希码、对象的GC分代年龄等信息;
5、对象初始化
其实在上面步骤完成之后,对虚拟机来说一个对象就创建出来了,但对开发人员来说,程序创建才正式开始,因为此时所有字段还都是零值;我们在这一步调用构造方法按照开发人员的意愿对对象进行初始化,这样一个真正可用的对象才算创建完成;
五、对象的内存布局、访问定位及判断存活:
1、对象的内存布局
1)、对象头
(1)存储对象自身的运行时数据
哈希码、GC分代年龄、锁状态标识、线程持有的锁、偏向线程ID等
(2)类型指针:主要标识对象属于的类的信息
(3)若为对象数组,还应记录数组的长度
2)、实例数据
3)、对齐填充(非必须)
2、对象的访问定位
例如通过栈中局部变量表中的引用去访问对象时有两种方式:使用句柄和直接指针
使用句柄
使用句柄是指在堆中维护了一个句柄池,引用首先指向句柄中的对象实例的指针和对象类型的指针,然后句柄中的指针再指向堆中的对象和方法区中的class类型;
直接指针
直接指针是指局部变量表中的引用直接指向堆中的对象;
3、判断对象的存活
引用计数算法
当一个对象被引用一次,引用计数就会加1;当一个对象被释放一次,引用计数就会减一。当对象的引用计数置为0后,就被标记为可回收。引用计数存在一个弊端,就是互相引用的问题,当两个对象在外部已经没有引用的时候,但他们互相引用,也会造成引用计数不为0的情况,从而无法回收;
可达性算法分析(根可达)
基本思路就是以一系列的GCRoots作为起始点,从这些节点向下搜索,走过的路径称为引用链,当一个对象通过引用链与GCROOTS直接或间接相连的话,就称为root可达;
可作为GCROOTS的对象有: 虚拟机栈中局部变量表中引用的对象 静态变量引用的对象 常量引用的对象 本地方法栈中引用的对象 所有被同步锁(例如synchronized)持有的对象
finalize方法
是不是只要对象不是root可达的,执行垃圾回收就一定会被回收呢?其实并不是,在Object中有finalize方法,当某个对象要被垃圾回收了,它会调用自身的finalize方法,我们可以在这个方法中对对象进行保活,但是这个方法只能执行一次,在第二次执行垃圾回收的时候就不会执行了;并且执行finalize的线程的优先级很低,所以并不一定能保证这个方法得到及时的执行,它是不可信赖的,所以我们不能依赖此方法对对象进行保活,并且从java9开始这个方法被弃用了;
六、对象的四种引用
1、强引用
我们平常new出来的对象都是强引用,强引用的意思就是如果一个对象存在GCROOTS可达的强引用,那么即使虚拟机抛出oom异常,也不会去回收强引用的对象;
2、软引用SoftReference
程序将要发生oom异常之前,会回收软引用的对象;如果回收了这些对象内存仍然不够,才会抛出oom异常;
在加载大图片的时候我们可以采用软引用方法,将图片的软引用提前加载到内存中提高效率,而又可以恰当的回收;
3、弱引用WeakReference
弱引用只能生存到下一次垃圾回收,在下一次垃圾回收发生时,不管内存够不够,都会将弱引用关联的对象回收掉;
弱引用的使用还是比较广泛的,比如ThreadLocal的实现中,在Map中存储的键值ThreadLocal就是一个弱引用;还有一个比较典型的应用就是解决Handler内存泄露问题,如果我们直接在activity中定义匿名内部类,则因为匿名内部类会持有外部类Activity的引用,从而造成activity退出但handler还持有activity导致activity不能被回收;解决方案就是创建静态内部类Handler,在静态内部类中创建外部类Activity的弱引用,从而可以访问activity的域或者方法而且不会导致acitivty无法回收的问题。
4、虚引用PhantomReference
虚引用又称之为幽灵引用,随时会被回收掉;在垃圾回收的时候会收到一个通知,因此虚引用的主要作用就是监控垃圾回收器是否正常工作;
七、对象的分配策略
1、对象是在栈上分配的还是在堆上分配的
我们在看虚拟机创建对象的时候经常会看到一句话,几乎所有的对象都是在堆上创建的;那么什么情况下会在栈上创建呢,就是方法中的对象满足逃逸分析的话就会在栈上创建;逃逸分析又分为方法逃逸和线程逃逸,如果一个方法中的对象不会逃逸到方法外,并且不会被外部线程访问到,我们才说满足逃逸分析;对象在栈上分配可以显著提高效率,并且不需要垃圾回收,因为栈空间是跟线程绑定的,当线程执行完毕,栈也就被释放了,从而栈中的对象也就没了。
2、大对象存放位置
jvm会根据创建的对象是否是大对象决定存放在堆的什么位置,如果是大对象,则会直接存放在老年代,如果不是则会放置在Eden区;大 对象一般指的是一些很长的字符串或者很长的数组。新生代和老年代的大小比例一般为1:2,在新生代中Eden:From:To = 8:1:1
3、对象的分配原则
对象优先在Eden区分配,即新创建的对象会优先放在Eden区;
空间分配担保:是指当对象进入From或者To区时如果空间不足,则会直接进入老年代;
长期存活的对象如何进入老年代:我们新创建的对象在Eden区,当进行垃圾回收之后Eden区存活的对象就会移动到From区或者To区,然后在From或者To区的对象会在对象头中记录对象年龄,每经过一次垃圾回收之后仍然存活的对象年龄加1,对象会在From和To区之间来回移动,当对象年龄达到指定条件就会移动到老年代。新生代的From区和To区是两块大小相等的区域,执行的垃圾回收算法是复制清除算法,效率很高但是空间利用率低,仅使用一半。
八、垃圾回收简介
1、分代收集理论:当代垃圾回收器大多遵循分代回收的理论;
绝大部分对象都是存活事件非常短;
经过多次垃圾回收之后仍然存活的对象就很难被回收掉了;
根据以上理论,于是就产生了新生代和老年代,新生代和老年代有自己特定的垃圾回收机制;
2、GC种类
新生代回收,又称为Young GC或者Minor GC,指对新生代区域的垃圾回收;新生代的垃圾回收算法一般为复制算法;
老年代回收,又称为Old GC或者Major GC,对老年代回收的定义并没有一个统一的规定,有的地方规定仅仅是对老年代的回收,而有的地方规定是对整个堆的回收;老年代的回收算法一般为标记清除算法或者标记整理算法;
整堆回收,又称为Full GC,指的是收集整个堆和方法区(注意包含方法区);
3、STW现象
STW全称Stop The World,指的是当垃圾回收线程进行垃圾回收的时候用户线程会暂停,当垃圾回收线程结束后用户线程恢复执行,采用STW是为了保证垃圾回收的效果,如果不暂停用户线程,那么就会一边制造垃圾一边回收垃圾;但STW机制的弊端也非常明显,会造成用户体验轻微卡顿;
因为垃圾回收触发的时刻一般是内存不够用了,如果此时不停止用户线程去回收垃圾,可能会造成收集对象的速度小于生产对象的速度,进而会造成oom。
九、垃圾回收算法
1、复制算法
复制算法又称为复制清除算法,它是指将空间分为两块大小相等的区域AB,每次只使用其中的一块区域,垃圾收集器首先将A区域中存活的对象标记出来,然后将存活的对象转移到B区域,最后将A区域整块清除;复制算法在存活对象少的情况下效率很高,因为只需要少量的复制移动,剩下的直接清除,因为这个特点所以说复制算法特别适合在新生代使用;并且复制算法还有一个比较明显的优点就是不会产生内存碎片,但复制算法的缺点也比较明显,就是空间利用率低,空间利用率仅仅为一半;
加入Eden区的优化:
如果将新生代划为AB两块区域,使用传统的复制算法导致空间利用率很低,因此引入了Eden区域,在新生代中Eden:From:To = 8:1:1,新创建的对象直接放进Eden区,而新建的对象绝大多数的存活时间非常短,所以在垃圾回收之后剩余对象非常少,然后再将剩余存活的对象放入From或者To区,同时在From和To区这两个区域执行标准的复制算法;使用这种方式空间利用率大约为90%,只会浪费From或者To一块空间。
2、标记清除算法
标记清除算法原理非常简单,就是垃圾回收器首先将需要回收的对象标记出来,然后直接将其回收;这种方法实现简单,但是缺点就是会产生内存碎片,而内存碎片过多时,分配较大的对象时如果没有一整块可以放下的区域又会提前触发GC,因此执行效率也不稳定。
3、标记整理算法
垃圾收集器首先将需要回收的对象进行标记,然后将剩余存活的对象都向区域的一端移动,最后将移动完之后剩余的部分直接清除掉;垃圾收集器需要将对象移动,因此其引用也需要进行更新,效率不是很高,用户线程暂停时间较长;其优点就是不会产生内存碎片
十、JVM中常见的垃圾收集器
新生代的常见垃圾收集器有Serial、Parallel Scavenge、ParNew
老年代的常见垃圾收集器有Serial Old、Parallel Old、CMS
这些收集器存在着对应的关系,从名字中也可以看出来;其中还有G1垃圾收集器,它是一个跨越新生代和老年代的收集器;
1、Serial与Serial Old
都是单线程的回收器,适用于单核的CPU,在新生代Serial中使用复制算法;在老年代Serial Old中使用标记整理算法;
2、Parallel与Parallel Old
这两个是并行的多线程垃圾回收器,这个并行是指多个垃圾回收线程之间并行处理;同样的在新生代Parallel中使用复制算法;在老年代Parallel Old使用标记整理算法;
3、ParNew
新生代的垃圾收集器,类似于Parallel,也是并行的多线程垃圾回收器,采用标记整理算法;
4、CMS
CMS全称Concurrent Mark Sweep,是在老年代中的垃圾回收器,它是一种并行与并发的多线程垃圾回收器;并行指的是垃圾回收线程之间并行处理,并发指的是垃圾回收线程和用户线程之间并发进行,说白了就是有些垃圾回收操作不需要用户线程暂停,我们前面说的这些垃圾回收器不管单线程还是多线程,在进行垃圾回收的时候都会暂停用户线程;
CMS工作流程:
首先是初始标记阶段,暂停用户线程,标记直接跟GCroot关联的对象;然后是并发标记,沿着引用链继续向下标记,此时垃圾收集线程和用户线程同时进行;接着是重新标记阶段,此时对标记进行一些整理操作,此时暂停用户线程;最后就是并发清理的过程,用户线程和垃圾回收线程同时进行。
CMS特点
优点是采用标记清除算法,实现简单,效率较高;并且采用部分操作和用户线程并发进行,用户体验好,垃圾回收时卡顿现象轻微;缺点是会产生内存碎片,并且会产生浮动垃圾,因为最后一步并发清理的时候是用户线程和垃圾收集线程同步进行,在清理的过程又会产生垃圾对象,称之为浮动垃圾。
5、G1
G1全称Garbage First,它也是一种并行与并发的垃圾收集器,它是一种跨越新生代和老年代的收集器,采用标记整理和化整为零的方式回收垃圾;化整为零的意思就是说把堆划分成一块块大小相同的区域,每块区域可能是Eden区、Survivor区、Old区或者Humongous区,其中Humongous区主要用来存放大对象。在新生代依然使用复制算法,在老年代使用标记整理算法。这两种算法均不会产生内存碎片。
CMS工作流程:
初始标记阶段,并发标记阶段,最终标记阶段,最后是筛选回收阶段。其中前三个阶段跟CMS基本相同,最后的筛选回收会选择合适的区域进行回收,并且会暂停用户线程。
十一、常量池与String
1、 常量池
jvm1.8之后,运行时常量池位于方法区,但字符串常量池位于堆中了;从jvm1.8开始方法区的实现也从永久代变成了元空间;
在虚拟机中规定的常量池一般包括静态常量池和运行时常量池;
静态常量池主要用来存放一些字面量、符号引用、类和方法的一些信息;
运行时常量池是在类加载完成之后,将静态常量池中的符号引用转存到运行时常量池中,类在解析之后,将符号引用替换为直接引用;
符号引用:包括类和方法的全限定名、字段的名称和描述符、方法的名称和描述符;
直接引用:具体对象的索引值;
2、String对象的创建:
看String的源码可以看到String类被修饰为final,不可被继承;其中维护了一个不可变的数组,final char[] value,所以String具有不可变性;String创建一般有两种方式:
1)、String str = "aaa";这种方式会去字符串常量池中查找是否有aaa,如果有,直接返回此引用;如果没有,则会在字符串常量池先创建aaa,然后将引用返回;
2)、String str = new String("aaa");这种方式会去字符串常量池先检查是否有aaa:如果有,会将引用返回,因为有new操作符,所以会在堆中创建String对象,将引用指向常量池中的aaa,最后将堆中的String的引用再返回;如果没有,则会在字符串常量池先创建aaa,然后后续操作跟常量池有aaa的情况相同;
3)、String str = new String("aaa").intern();这种方式会去字符串常量池中检查是否有aaa,如果有直接返回引用,因此下面两个String对象进行==比较时返回true;
String str1 = new String("abc").intern(); String str2 = new String("abc").intern(); System.out.println(str1 == str2);此时返回true