JVM探究
- 请你谈谈你对JVM的理解?java8虚拟机和之前的变化更新?
- 什么是OOM,什么是栈溢出StackOverError?怎么分析
- JVM的常用调优参数有哪些?
- 内存快照如何抓取,怎么分析Dump文件?怎么分析?
- 谈谈JVM中,类加载器你的认识?
1.JVM的位置
JVM:它是整个java实现跨平台的最核心的部分,由Java文件编译来的class文件,只有经过虚拟机解释才能被操作系统执行。一次编译,多处运行:JVM屏蔽了与具体操作系统平台相关的信息,使得Java程序只需生成在Java虚拟机上运行的class文件(字节码),就可以在多种平台上不加修改地运行。也就是在程序运行前,Java源程序(.java)需要经过编译器编译成字节码(.class)。在程序运行时,JVM负责将字节码文件翻译成机器码并运行,也就是说,只要在不同平台上安装对应的JVM,就可以运行字节码文件。
JVM位置:
JVM,JRE,JDK的关系:JDK包含JRE,JRE包含JVM。
2.JVM的体系结构
Java运行时数据区的内存区域简介:
- 程序计数器: 指向当前线程正在执行的字节码的地址,行号。(记录线程执行到哪里了,为了防止线程被挂起后,重新唤醒从执行到的地方再次执行)
- Java 虚拟机栈(线程栈): 一个线程对应一块线程栈空间,每个方法在执行的同时都会在线程栈中创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在线程栈中入栈到出栈的过程。
- 本地方法栈: 同虚拟机栈,不同的是,它存的是本地方法的数据。
- 堆 Heap: 在JVM启动时创建的一块内存区域,是被所有Java线程共享的,不是线程安全的。堆是存储Java对象的地方,保存了所有的对象实例和数组。也是GC(内存回收)管理的主要区域,可以细分为:新生代、老年代、永久代;新生代又分为Eden空间、From Survivor(幸存)空间、To Survivor空间。
- 方法区 Method Area: 是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
- 常量池 Constant Pool: 是方法区的一个数据结构,用于保存编译期间生成的各种字节码和常量字符串等数据。
3.类加载机制
-
一个Java类从编码到最终完成执行,包括两个过程:编译运行。
编译:通过javac命令将.java文件编程成.class文件。
运行:将.class文件通过类加载器加载到内存中,并运行。 -
类在JVM中的生命周期: 加载、链接(验证、准备、解析)、初始化、使用、卸载。
-
类的加载时机:
JVM运行的时候,并不是一次性加载所有类的,而是使用到哪个就加载哪个,并且只会加载一次。
1、new 一个对象实例的时候。
2、访问类或接口的静态变量,或者给静态变量赋值。
3、调用类的静态方法。
4、反射 Class.forName(“com.demo.ClassA”)。
5、初始化一个子类,首先会初始化父类。
类装载器 ClassLoader
-
类装载器 ClassLoader 是负责加载class文件的,将class文件字节码内容加载到内存中,并将这些内容转换成方法区中的运行时数据结构。ClassLoader只负责文件的加载,至于它是否可运行,则由Execution Engine决定。
-
这里需要区分一下class与Class
- 小写的class,是指使用javac命令编译 Java 代码后所生成的以.class为后缀名的字节码文件。
- 而大写的Class,是 JDK 提供的java.lang.Class,可以理解为封装类的模板。多用于反射场景,例如 JDBC 中的加载驱动,Class.forName(“com.mysql.jdbc.Driver”);
-
下图Car.class字节码文件被ClassLoader类装载器加载并初始化,在方法区中生成了一个Car Class的类模板,而平时所用到的实例化,就是在这个类模板的基础上,形成了一个个实例,即car1,car2。反过来讲,可以对某个具体的实例进行getClass()操作,就可以得到该实例的类模板,即Car Class。再接着,对这个类模板进行getClassLoader()操作,就可以得到这个类模板是由哪个类装载器进行加载的。
Tip: 扩展一下,JVM并不仅仅只是通过检查文件后缀名是否是.class来判断是否加载,最主要的是通过class文件中特定的文件标示,即下图test.class文件中的cafe babe。
有哪些类装载器
1、虚拟机自带的类加载器
-
启动类加载器(Bootstrap),也叫根加载器,加载%JAVAHOME%/jre/lib/rt.jar
-
扩展类加载器(Extension),加载%JAVAHOME%/jre/lib/ext/*.jar,例如javax.swing包
-
应用程序类加载器(AppClassLoader),也叫系统类加载器,加载%CLAPATH%的所有类
2、 用户自定义的加载器 : 用户可以自定义类的加载方式,但必须是Java.lang.ClassLoader的子类。
4.双亲委派机制和沙箱安全机制
- 父类委托机制。
通过下面代码来观察这几个类加载器。首先,我们先看自定义的MyObject,首先通过getClassLoader()获取到的是AppClassLoader,然后getParent()得到ExtClassLoader,再getParent()竟然是null?可能大家会有疑惑,不应该是Bootstrap加载器么?这是因为,BootstrapClassLoader是使用C++语言编写的,Java在加载的时候就成了null。
package JVM_base;
public class Car {
public int age;
public static void main(String[] args) {
Car car1 = new Car();
Car car2 = new Car();
Car car3 = new Car();
System.out.println(car1.hashCode());
System.out.println(car2.hashCode());
System.out.println(car3.hashCode());
Class<? extends Car> aClass = car1.getClass();
System.out.println("类模板是"+aClass);
ClassLoader classLoader = aClass.getClassLoader();
System.out.println("类加载器是"+classLoader+"应用程序加载器");
System.out.println("再往上是"+classLoader.getParent()+"扩展类加载器");
System.out.println("再往上是"+classLoader.getParent().getParent());//null 1.不存在2.java程序获取不到
}
}
2129789493
668386784
1329552164
类模板是class JVM_base.Car
类加载器是jdk.internal.loader.ClassLoaders$AppClassLoader@2f0e140b应用程序加载器
再往上是jdk.internal.loader.ClassLoaders$PlatformClassLoader@12edcd21扩展类加载器
再往上是null启动类加载器
-
我们再来看Java自带的Object,通过getClassLoader()获取到的加载器直接就是BootstrapClassLoader,如果要想getParent()的话,因为是null值,所以就会报java.lang.NullPointerException空指针异常。
-
输出中,sun.misc.Launcher是JVM相关调用的入口程序。
- 自定义了一个java.lang.String类,并且创建main方法后运行,发现报错了,提示找不到main方法,但是明明我们定义了main方法啊,引出双亲委派和沙箱安全。
(1)双亲委派
当一个类收到了类加载请求,它首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,因此所有的加载请求都应该传送到启动类加载器中,只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的Class),子类加载器才会尝试自己去加载。
采用双亲委派的一个好处是,比如加载位于rt.jar包中的类java.lang.Object,不管是哪个加载器加载这个类,最终都是委派给顶层的启动类加载器进行加载,确保哪怕使用了不同的类加载器,最终得到的都是同样一个Object对象。
(2)沙箱安全
是基于双亲委派机制上采取的一种JVM的自我保护机制,假设你要写一个java.lang.String的类,由于双亲委派机制的原理,此请求会先交给BootStrapClassLoader试图进行加载,但是BootStrapClassLoader在加载类时首先通过包和类名查找rt.jar中有没有该类,有则优先加载rt.jar包中的类,因此就保证了java的运行机制不会被破坏,确保你的代码不会污染到Java的源码。保证了大家使用的类是同一套体系的,统一的class。保证java源代码不受污染,保证源码干净一致,这叫沙箱安全机制。
类加载器的加载顺序如下:
- 当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
- 当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
- 如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载。
- 若ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。
- Tip: rt.jar是什么?为什么可以在idea这些开发工具中可以直接去使用String、ArrayList、甚至一些JDK提供的类和方法?因为这些都在rt.jar中定义好了,且直接被启动类加载器进行加载了。
5.方法区
方法区的理解
-
存储:static,final,Class,常量池
-
怎么理解:虚拟机规范中将方法区看做是堆的逻辑部分,但是对于HotSpotJVM实现上,将堆和方法区分开,认为是两个不同的结构, 方法区还有一个别名是Non-Heap(非堆),目的就是要和堆分开。也可以理解new出来的都在堆里面,方法区里面放的是类的信息。
所以,方法区可以看作是一块独立于Java堆的内存空间。
1、方法区主要存放的是 Class,而堆中主要存放的是实例化的对象
2、方法区(Method Area)与Java堆一样,是各个线程共享的内存区域
3、方法区在JVM启动的时候被创建,并且它的实际的物理内存空间中和Java堆区一样都可以是不连续的。
4、方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
5、方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:
java.lang.OutofMemoryError:PermGen space(JDK7及之前) 或者 java.lang.OutOfMemoryError:Metaspace(JDK8及之后)
举例说明方法区 OOM
1、 加载大量的第三方的jar包
2、Tomcat部署的工程过多(30~50个)
3、大量动态的生成反射类
6、关闭JVM就会释放这个区域的内存。
6.栈
栈:先进后出,后进先出
- 8打基本类型+对象引用+实例方法
- 栈运行原理:栈帧
- 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。
- 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
- Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。
队列:先进先出(FIFO)
7.堆
- 一个JVM只有一个堆内存。堆内存的大小是可以调节的
- 类加载器读取类文件后,一般会把什么东西放到堆中呢?类,方法,常量,变量,保存我们引用的真实对象
- 堆内存三个区域:
- 新生区
- 养老区
- 永久区
年轻代(新生区)
所有新生成的对象首先都是放在年轻代。年轻代的目标就是尽可能快速的收集掉那些生命周期短的对象。年轻代一般分3个区,1个伊甸区,2个幸存者区(从到)。
大部分对象在伊甸区中生成。当伊甸区满时,还存活的对象将被复制到幸存者区(两个中的一个),当一个幸存者区满时,此区的存活对象将被复制到另外一个幸存者区,当另一个幸存者区也满了的时候,从前一个幸存者区复制过来的并且此时还存活的对象,将可能被复制到年老代。
2个幸存者区是对称的,没有先后关系,所以同一个幸存者区中可能同时存在从伊甸区复制过来对象,和从另一个幸存者区复制过来的对象;而复制到年老区的只有另一个从幸存者区过来的对象。而且,因为需要交换的原因,幸存者区至少有一个是空的。特殊的情况下,根据程序需要,幸存者区是可以配置为多个的(多于2个),这样可以增加对象在年轻代中的存在时间,减少被放到年老代的可能。
针对年轻代的垃圾回收即Young GC。
年老代
在年轻代中经历了Ñ次(可配置)垃圾回收后仍然存活的对象,就会被复制到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。
针对年老代的垃圾回收即Full GC。
持久代(元空间,永久区)
用于存放静态类型数据,如Java的的的类,方法等。持久代对垃圾回收没有显着影响。但是有些应用可能动态生成或调用一些类,例如休眠CGLIB等,在这种时候往往需要设置一个比较大的持久代空间来存放这些运行过程中动态增加的类型。
一组对象生成时,内存申请过程如下:
- JVM会试图为相关的Java的的的对象在年轻代的伊甸园区中初始化一块内存区域。
- 当伊甸区空间足够时,内存申请结束。否则执行下一步。
- JVM试图释放在伊甸园区中所有不活跃的对象(年轻的GC)。释放后若伊甸园空间仍然不足以放入新对象,JVM则试图将部分伊甸园区中活跃对象放入幸存者区。
- 幸存者区被用来作为伊甸园区及年老代的中间交换区域。当年老代空间足够时,幸存者区中存活了一定次数的对象会被移到年老代。
- 当年老代空间不够时,JVM会在年老代进行完全的垃圾回收(Full GC)。
- 完全GC后,若幸存者区及年老代仍然无法存放从伊甸区复制过来的对象,则会导致JVM无法在伊甸园区为新生成的对象申请内存,即出现“内存不足”。
OOM(“Out of Memory”)异常一般主要有如下2种原因:
1.年老代溢出,表现为:java.lang.OutOfMemoryError:Javaheapspace
这是最常见的情况,产生的原因可能是:设置的内存参数XMX过小或程序的内存泄露及使用不当问题。
例如**循环上万次的字符串处理,创建上千万个对象,在一段代码内申请上百中号甚至上ģ的内存。还有的时候虽然不会报内存溢出,却会使系统不间断的垃圾回收,也无法处理其它请求。**这种情况下除了检查程序,打印堆内存等方法排查,还可以借助一些内存分析工具,比如MAT就很不错。
2.持久代溢出,表现为:java.lang.OutOfMemoryError:PermGenspace
通常由于持久代设置过小,动态加载了大量的Java类而导致溢出,解决办法唯有将参数-XX:MaxPermSize参数参数参数调大(一般256米能满足绝大多数应用程序需求)将部分的Java的的类放到容器共享区(例如Tomcat share lib)去加载的办法也是一个思路,但前提是容器里部署了多个应用,而这些应用有大量的共享类库。
方法区三种情况:
方法区是堆上的一个概念,具体的落地实现是永久代或者 元空间,它们都统称方法区。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。 不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存
1、 java7之前,方法区位于永久代(PermGen),永久代和堆相互隔离,永久代的大小在启动JVM时可以设置一个固定值,不可变;
2、 java7中,static变量从永久代移到堆中;
3、 java8中,取消永久代,方法存放于元空间(Metaspace),元空间仍然与堆不相连,但与堆共享物理内存,逻辑上可认为在堆中
idea的VM options命令
- -Xms 设置初始化内存(堆内存)分配大小,默认是电脑内存的1/64
- -Xmx 设置最大分配内存,默认是电脑内存的1/4
- -XX:+PrintGCDetails 打印GC垃圾回收信息
- -XX:+HeapDumpOnOutOfMemoryError //oom dump信息
- 使用:-Xms1024m -Xmx1024m -XX:+heapDumpOnOutOfMemoryError
- -XX:MaxTenuringThreshold=5 通过这个参数可以设定进入老年代的时间,默认是15次。
-Xms1024m,设置JVM初始堆内存为1024m。此值可以设置与-Xmx相同,以避免每次垃圾回收完成后JVM重新分配内存。
-Xmx1024m,设置JVM最大堆内存为1024m。
-Xss512k,设置每个线程的栈大小。JDK5.0以后每个线程栈大小为1M,之前每个线程栈大小为256K。在相同物理内存下,减小这个值能生成更多的线程,当然操作系统对一个进程内的线程数还是有限制的,不能无限生成。线程栈的大小是个双刃剑,如果设置过小,可能会出现栈溢出,特别是在该线程内有递归、大的循环时出现溢出的可能性更大,如果该值设置过大,就有影响到创建栈的数量,如果是多线程的应用,就会出现内存溢出的错误。
-Xmn341m,设置年轻代大小为341m。在整个堆内存大小确定的情况下,增大年轻代将会减小年老代,反之亦然。此值关系到JVM垃圾回收,对系统性能影响较大,官方推荐配置为整个堆大小的3/8。
-XX:NewSize=341m,设置年轻代初始值为341M。
-XX:MaxNewSize=341m,设置年轻代最大值为341M。
-XX:PermSize=512m,设置持久代初始值为512M,但在java8及之后就不支持了,改用-XX:MetaspaceSize=512m。
-XX:MaxPermSize=512m,设置持久代最大值为512M,同样在java8及之后就不支持了,改用-XX:MaxMetaspaceSize=512m。
-XX:NewRatio=2,设置年轻代(包括1个Eden和2个Survivor区)与年老代的比值。表示年轻代比年老代为1:2。
-XX:SurvivorRatio=8,设置年轻代中Eden区与Survivor区的比值。表示2个Survivor区(JVM堆内存年轻代中默认有2个大小相等的Survivor区)与1个Eden区的比值为1:1:8,即1个Survivor区占整个年轻代大小的1/10。
-XX:MaxTenuringThreshold=15,具体参看JVM系列之内存分配和回收策略中对象的衰老过程。
-XX:ReservedCodeCacheSize=256m,设置代码缓存的大小,用来存储已编译方法生成的本地代码。
-client,设置JVM使用Client模式,特点是启动速度比较快,但运行时性能和内存管理效率不高,通常用于客户端应用程序或开发调试;在32位环境下直接运行Java程序默认启用该模式。
-server,设置JVM使Server模式,特点是启动速度比较慢,但运行时性能和内存管理效率很高,适用于生产环境。在具有64位能力的JDK环境下默认启用该模式。
-Dserver.port=8084,设置服务端口为8084
如何快速解决OOM故障
- 利用内存快照工具:JProfiler、MAT
- JProfiler作用:
- 分析Dumo内存文件,快速定位内存泄露
- 获得堆中的数据
package JVM_base;
import java.util.ArrayList;
//-Xms1m -Xms8m -XX:+HeapDumpOnOutOfMemoryError
public class Demo03 {
byte[] array = new byte[1*1024*1024];
public static void main(String[] args) {
ArrayList<Demo03>list = new ArrayList<>();
int count = 0;
try {
while (true){
list.add(new Demo03());
count = count + 1;
}
}catch (Exception e){
System.out.println("count"+count);
e.printStackTrace();
}
}
}
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid1488.hprof ...
Heap dump file created [1052648334 bytes in 9.218 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at JVM_base.Demo03.<init>(Demo03.java:6)
at JVM_base.Demo03.main(Demo03.java:13)
Process finished with exit code 1
8.GC
今天我们来谈谈Java主流虚拟机-HotSpot的GC实现机制,本篇文章默认使用HotSpot虚拟机进行介绍,如果没有特殊说明,其都为HotSpot虚拟机中的特性。
Java与C++之间有一堵由内存动态分配和垃圾收集技术所围城的“高墙”,墙外面的人想进去,墙里面的人却想出来。
说起垃圾收集,大部分人都把这项技术当做Java语言的伴生产物。事实上,GC的历史比Java久远,1960年诞生与MIT的Lisp是第一门真正使用内存动态分配和垃圾收集技术的语言。关于Garbage Collection的历史这里就不多说了,因为这是一篇技术博客而不是来将历史的,如果对GC的发展历史感兴趣可以自行百度。
一、GC实现机制-我们为什么要去了解GC和内存分配?
说道这个问题,我有一个简单的回答:在真实工作中的项目中,时不时的会发生内存溢出、内存泄露的问题,这也是不可避免的Bug,这些潜在的Bug在某些时候会影响到项目的正常运行,如果你的项目没有合理的进行业务内存分配,将会直接影响到的项目的并发处理,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节,而了解了GC实现机制则是我们一切监控和调节的前提。
二、GC实现机制-Java虚拟机将会在什么地方进行垃圾回收?
说起垃圾回收的场所,了解过JVM(Java Virtual Machine Model)内存模型的朋友应该会很清楚,堆是Java虚拟机进行垃圾回收的主要场所,其次要场所是方法区。
三、GC实现机制-Java虚拟机具体实现流程
我们都知道在Java虚拟机中进行垃圾回收的场所有两个,一个是堆,一个是方法区。在堆中存储了Java程序运行时的所有对象信息,而垃圾回收其实就是对那些“死亡的”对象进行其所侵占的内存的释放,让后续对象再能分配到内存,从而完成程序运行的需要。关于何种对象为死亡对象,在下一部分将做详细介绍。Java虚拟机将堆内存进行了“分块处理”,从广义上讲,在堆中进行垃圾回收分为新生代(Young Generation)和老生代(Old Generation);从细微之处来看,为了提高Java虚拟机进行垃圾回收的效率,又将新生代分成了三个独立的区域(这里的独立区域只是一个相对的概念,并不是说分成三个区域以后就不再互相联合工作了),分别为:Eden区(Eden Region)、From Survivor区(Form Survivor Region)以及To Survivor(To Survivor Region),而Eden区分配的内存较大,其他两个区较小,每次使用Eden和其中一块Survivor。Java虚拟机在进行垃圾回收时,将Eden和Survivor中还存活着的对象进行一次性地复制到另一块Survivor空间上,直到其两个区域中对象被回收完成,当Survivor空间不够用时,需要依赖其他老年代的内存进行分配担保。当另外一块Survivor中没有足够的空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老生代,在老生代中不仅存放着这一种类型的对象,还存放着大对象(需要很多连续的内存的对象),当Java程序运行时,如果遇到大对象将会被直接存放到老生代中,长期存活的对象也会直接进入老年代。如果老生代的空间也被占满,当来自新生代的对象再次请求进入老生代时就会报OutOfMemory异常。新生代中的垃圾回收频率高,且回收的速度也较快。就GC回收机制而言,JVM内存模型中的方法区更被人们倾向的称为永久代(Perm Generation),保存在永久代中的对象一般不会被回收。其永久代进行垃圾回收的频率就较低,速度也较慢。永久代的垃圾收集主要回收废弃常量和无用类。以String常量abc为例,当我们声明了此常量,那么它就会被放到运行时常量池中,如果在常量池中没有任何对象对abc进行引用,那么abc这个常量就算是废弃常量而被回收;判断一个类是否“无用”,则需同时满足三个条件:
(1)、该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
(2)、加载该类的ClassLoader已经被回收
(3)、该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述3个条件的无用类进行回收,这里说的是可以回收而不是必然回收。
大多数情况下,对象在新生代Eden区中分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC;同理,当老年代中没有足够的内存空间来存放对象时,虚拟机会发起一次Major GC/Full GC。只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full CG。
虚拟机通过一个对象年龄计数器来判定哪些对象放在新生代,哪些对象应该放在老生代。如果对象在Eden出生并经过一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将该对象的年龄设为1。对象每在Survivor中熬过一次Minor GC,年龄就增加1岁,当他的年龄增加到最大值15时,就将会被晋升到老年代中。虚拟机并不是永远地要求对象的年龄必须达到MaxTenuringThreshold才能晋升到老年代,如果在Survivor空间中所有相同年龄的对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄。
四、GC实现机制-Java虚拟机如何实现垃圾回收机制
(1)、引用计数算法(Reference Counting)
给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的,这就是引用计数算法的核心。客观来讲,引用计数算法实现简单,判定效率也很高,在大部分情况下都是一个不错的算法。但是Java虚拟机并没有采用这个算法来判断何种对象为死亡对象,因为它很难解决对象之间相互循环引用的问题。
2)、可达性分析算法(Reachability Analysis)
这是Java虚拟机采用的判定对象是否存活的算法。通过一系列的称为“GC Roots"的对象作为起始点,从这些结点开始向下搜索,搜索所走过的路径称为饮用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。可作为GC Roots的对象包括:虚拟机栈中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象。本地方法栈JNI引用的对象。
在上图可以看到GC Roots左边的对象都有引用链相关联,所以他们不是死亡对象,而在GCRoots右边有几个零散的对象没有引用链相关联,所以他们就会别Java虚拟机判定为死亡对象而被回收。
五、GC实现机制-何为死亡对象?
Java虚拟机在进行死亡对象判定时,会经历两个过程。如果对象在进行可达性分析后没有与GC Roots相关联的引用链,则该对象会被JVM进行第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法,如果当前对象没有覆盖该方法,或者finalize方法已经被JVM调用过都会被虚拟机判定为“没有必要执行”。如果该对象被判定为没有必要执行,那么该对象将会被放置在一个叫做F-Queue的队列当中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它,在执行过程中JVM可能不会等待该线程执行完毕,因为如果一个对象在finalize方法中执行缓慢,或者发生死循环,将很有可能导致F-Queue队列中其他对象永久处于等待状态,甚至导致整个内存回收系统崩溃。如果在finalize方法中该对象重新与引用链上的任何一个对象建立了关联,即该对象连上了任何一个对象的引用链,例如this关键字,那么该对象就会逃脱垃圾回收系统;如果该对象在finalize方法中没有与任何一个对象进行关联操作,那么该对象会被虚拟机进行第二次标记,该对象就会被垃圾回收系统回收。值得注意的是finaliza方法JVM系统只会自动调用一次,如果对象面临下一次回收,它的finalize方法不会被再次执行。
六、再探GC实现机制-垃圾收集算法
(1)、标记-清楚算法(Mark-Sweep)
用在老生代中, 先对对象进行标记,然后清楚。标记过程就是第五部分提到的标记过程。值得注意的是,使用该算法清楚过后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
(2)、复制算法(Copying)
用在新生代中,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活的对象复制到另外一块上,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可。
七、总结
- 内存效率:复制算法>标记清除算法>标记压缩算法(时间复杂度)
- 内存整齐度:复制算法>标记压缩算法>标记清除算法
- 内存利用率:标记压缩算法=标记清除算法>复制算法
9.JMM
1.什么是JMM
Java Memory Model(java内存模型)
内存模型可以理解为在特定的操作协议下,对特定的内存或者高速缓存进行读写访问的过程抽象描述,不同架构下的物理机拥有不一样的内存模型,Java虚拟机是一个实现了跨平台的虚拟系统,因此它也有自己的内存模型,即Java内存模型(Java Memory Model, JMM)。因此它不是对物理内存的规范,而是在虚拟机基础上进行的规范从而实现平台一致性,以达到Java程序能够“一次编写,到处运行”。
内存模型描述了程序中各个变量(实例域、静态域和数组元素)之间的关系,以及在实际计算机系统中将变量存储到内存和从内存中取出变量这样的底层细节
作用:缓冲一致性协议,用于定义数据的读写的规则
2.JMM结构规范
JMM规定了所有的变量都存储在主内存(Main Memory)中。每个线程还有自己的工作内存(Working Memory),线程的工作内存中保存了该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量(volatile变量仍然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般)。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成。
在java中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享(本文使用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量(Local variables),方法定义参数(java语言规范称之为formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
主内存和本地内存结构
从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。**本地内存是JMM的一个抽象概念,并不真实存在。**本地内存它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化之后的一个数据存放位置
从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:
- 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
- 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。
如上图所示,本地内存A和B有主内存中共享变量x的副本。假设初始时,这三个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。
从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。
3.JMM的三个特征
(1)原子性(Atomicity)
一个操作不能被打断,要么全部执行完毕,要么不执行。在这点上有点类似于事务操作,要么全部执行成功,要么回退到执行该操作之前的状态。
基本类型数据的访问大都是原子操作,long 和double类型的变量是64位,但是在32位JVM中,32位的JVM会将64位数据的读写操作分为2次32位的读写操作来进行,这就导致了long、double类型的变量在32位虚拟机中是非原子操作,数据有可能会被破坏,也就意味着多个线程在并发访问的时候是线程非安全的。
(2)可见性
一个线程对共享变量做了修改之后,其他的线程立即能够看到(感知到)该变量的这种修改(变化)。Java内存模型是通过将在工作内存中的变量修改后的值同步到主内存,在读取变量前从主内存刷新最新值到工作内存中,这种依赖主内存的方式来实现可见性的。
- 无论是普通变量还是volatile变量都是如此,区别在于:volatile的特殊规则保证了volatile变量值修改后的新值立刻同步到主内存,每次使用volatile变量前立即从主内存中刷新,因此volatile保证了多线程之间的操作变量的可见性,而普通变量则不能保证这一点。
除了volatile关键字能实现可见性之外,还有synchronized,Lock,final也是可以的。
- 使用synchronized关键字,在同步方法/同步块开始时(Monitor Enter),使用共享变量时会从主内存中刷新变量值到工作内存中(即从主内存中读取最新值到线程私有的工作内存中),在同步方法/同步块结束时(Monitor Exit),会将工作内存中的变量值同步到主内存中去(即将线程私有的工作内存中的值写入到主内存进行同步)。
- 使用Lock接口的最常用的实现ReentrantLock(重入锁)来实现可见性:当我们在方法的开始位置执行lock.lock()方法,这和synchronized开始位置(Monitor Enter)有相同的语义,即使用共享变量时会从主内存中刷新变量值到工作内存中(即从主内存中读取最新值到线程私有的工作内存中),在方法的最后finally块里执行lock.unlock()方法,和synchronized结束位置(Monitor Exit)有相同的语义,即会将工作内存中的变量值同步到主内存中去(即将线程私有的工作内存中的值写入到主内存进行同步)。
- final关键字的可见性是指:被final修饰的变量,在构造函数数一旦初始化完成,并且在构造函数中并没有把“this”的引用传递出去(“this”引用逃逸是很危险的,其他的线程很可能通过该引用访问到只“初始化一半”的对象),那么其他线程就可以看到final变量的值。
(3)有序性
对于一个线程的代码而言,我们总是以为代码的执行是从前往后的,依次执行的。这么说不能说完全不对,在单线程程序里,确实会这样执行;但是在多线程并发时,程序的执行就有可能出现乱序。用一句话可以总结为:在本线程内观察,操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行语义(WithIn Thread As-if-Serial Semantics)”,后半句是指“指令重排”现象和“工作内存和主内存同步延迟”现象。
4.内存交互操作
内存交互操作有8种,虚拟机实现必须保证每一个操作都是原子的,不可在分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台上允许例外)
-
- lock (锁定):作用于主内存的变量,把一个变量标识为线程独占状态
- unlock (解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
- read (读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
- load (载入):作用于工作内存的变量,它把read操作从主存中变量放入工作内存中
- use (使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令
- assign (赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中
- store (存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用
- write (写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中
JMM对这八种指令的使用,制定了如下规则:
-
- 不允许read和load、store和write操作之一单独出现。即使用了read必须load,使用了store必须write
- 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存
- 不允许一个线程将没有assign的数据从工作内存同步回主内存
- 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是怼变量实施use、store操作之前,必须经过assign和load操作
- 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁
- 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新load或assign操作初始化变量的值
- 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量
- 对一个变量进行unlock操作之前,必须把此变量同步回主内存
JMM对这八种操作规则和对volatile的一些特殊规则就能确定哪里操作是线程安全,哪些操作是线程不安全的了。但是这些规则实在复杂,很难在实践中直接分析。所以一般我们也不会通过上述规则进行分析。更多的时候,使用java的happen-before规则来进行分析。
5.volatile关键字
volatile在java语言中是一个关键字,用于修饰变量。被volatile修饰的变量后,表示这个变量在不同线程中是共享,编译器与运行时都会注意到这个变量是共享的,因此不会对该变量进行重排序。上面这句话可能不好理解,但是存在两个关键,共享和重排序。
public class VolatileTest {
boolean isStop = false;
public void test() {
Thread t1 = new Thread() {
@Override
public void run() {
isStop = true;
}
};
Thread t2 = new Thread() {
@Override
public void run() {
while (!isStop) {
}
}
};
t2.start();
t1.start();
}
public static void main(String args[]) throws InterruptedException {
new VolatileTest().test();
}
}
上面的代码是一种典型用法,检查某个标记(isStop)的状态判断是否退出循环。但是上面的代码有可能会结束,也可能永远不会结束。因为每一个线程都拥有自己的工作内存,当一个线程读取变量的时候,会把变量在自己内存中拷贝一份。之后访问该变量的时候都通过访问线程的工作内存,如果修改该变量,则将工作内存中的变量修改,然后再更新到主存上。这种机制让程序可以更快的运行,然而也会遇到像上述例子这样的情况。
存在一种情况,isStop变量被分别拷贝到t1、t2两个线程中,此时isStop为false。t2开始循环,t1修改本地isStop变量称为true,并将isStop=true回写到主存,但是isStop已经在t2线程中拷贝过一份,t2循环时候读取的是t2 工作内存中的isStop变量,而这个isStop始终是false,程序死循环。我们称t2对t1更新isStop变量的行为是不可见的。
如果isStop变量通过volatile进行修饰,t2修改isStop变量后,会立即将变量回写到主存中,并将t1里的isStop失效。t1发现自己变量失效后,会重新去主存中访问isStop变量,而此时的isStop变量已经变成true。循环退出。
volatile boolean isStop = false;
volatile怎么用
volatile关键字一般用于标记变量的修饰,类似上述例子。《Java并发编程实战》中说,volatile只保证可见性,而加锁机制既可以确保可见性又可以确保原子性。当且仅当满足以下条件下,才应该使用volatile变量:
1、对变量的写入操作不依赖变量的当前值,或者确保只有单个线程变更变量的值。
2、该变量不会于其他状态一起纳入不变性条件中
3、在访问变量的时候不需要加锁。