JVM

205 阅读1小时+

JVM内存区域

从上图可知,JVM主要包括四个部分:

1.类加载器(ClassLoader):在JVM启动时或者在类运行将需要的class加载到JVM中。(下图表示了从java源文件到JVM的整个过程,可配合理解。

2.执行引擎:负责执行class文件中包含的字节码指令。

3.内存区(也叫运行时数据区):是在JVM运行的时候操作所分配的内存区。运行时内存区主要可以划分为5个区域,如图:

方法区(MethodArea):用于存储类结构信息的地方,包括常量池、静态常量、构造函数等。虽然JVM规范把方法区描述为堆的一个辑部分, 但它却有个别名non-heap(非堆),所以大家不要搞混淆了。方法区还包含一个运行时常量池。

java堆(Heap):存储java实例或者对象的地方。这块是GC的主要区域。从存储的内容我们可以很容易知道,方法和堆是被所有java线程共享的。

java栈(Stack):java栈总是和线程关联在一起,每当创一个线程时,JVM就会为这个线程创建一个对应的java栈在这个java栈中,其中又会包含多个栈帧,每运行一个方法就建一个栈帧,用于存储局部变量表、操作栈、方法返回等。每一个方法从调用直至执行完成的过程,就对应一栈帧在java栈中入栈到出栈的过程。所以java栈是现成有的。

程序计数器(PCRegister):用于保存当前线程执行的内存地址。由于JVM程序是多线程执行的(线程轮流切换),所以为了保证程切换回来后,还能恢复到原先状态,就需要一个独立计数器,记录之前中断的地方,可见程序计数器也是线程私有的。

本地方法栈(Native MethodStack):和java栈的作用差不多,只不过是为JVM使用到native方法服务的。

4.本地方法接口:主要是调用C或C++实现的本地方法及回调结果。

类的加载过程,Person person = new Person();为例进行说明。

1).因为new用到了Person.class,所以会先找到Person.class文件,并加载到内存中;

2).执行该类中的static代码块,如果有的话,给Person.class类进行初始化;

3).在堆内存中开辟空间分配内存地址;

4).在堆内存中建立对象的特有属性,并进行默认初始化;

5).对属性进行显示初始化;

6).对对象进行构造代码块初始化;

7).对对象进行与之对应的构造函数进行初始化;

8).将内存地址付给栈内存中的p变量。

Java中对象的生命周期

在Java中,对象的生命周期包括以下几个阶段:

1.创建阶段(Created)

JVM 加载类的class文件 此时所有的static变量和static代码块将被执行 加载完成后,对局部变量进行赋值(先父后子的顺序) 再执行new方法 调用构造函数 一旦对象被创建,并被分派给某些变量赋值,这个对象的状态就切换到了应用阶段。

2.应用阶段(In Use)

对象至少被一个强引用持有着。

3.不可见阶段(Invisible)

当一个对象处于不可见阶段时,说明程序本身不再持有该对象的任何强引用,虽然该这些引用仍然是存在着的。 简单说就是程序的执行已经超出了该对象的作用域了。

4.不可达阶段(Unreachable)

对象处于不可达阶段是指该对象不再被任何强引用所持有。 与“不可见阶段”相比,“不可见阶段”是指程序不再持有该对象的任何强引用,这种情况下,该对象仍可能被JVM等系统下的某些已装载的静态变量或线程或JNI等强引用持有着,这些特殊的强引用被称为”GC root”。存在着这些GC root会导致对象的内存泄露情况,无法被回收。

5.收集阶段(Collected)

当垃圾回收器发现该对象已经处于“不可达阶段”并且垃圾回收器已经对该对象的内存空间重新分配做好准备时,则对象进入了“收集阶段”。如果该对象已经重写了finalize()方法,则会去执行该方法的终端操作。

6.终结阶段(Finalized)

当对象执行完finalize()方法后仍然处于不可达状态时,则该对象进入终结阶段。在该阶段是等待垃圾回收器对该对象空间进行回收。

7.对象空间重分配阶段(De-allocated)

垃圾回收器对该对象的所占用的内存空间进行回收或者再分配了,则该对象彻底消失了,称之为"对象空间重新分配阶段"。

并发

线程之间的通信和同步。

线程之间的通信

线程的通信是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种共享内存和消息传递。

在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信,典型的共享内存通信方式就是通过共享对象进行通信。

在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信,在java中典型的消息传递方式就是wait()和notify()。

线程之间的同步

同步是指程序用于控制不同线程之间操作发生相对顺序的机制。

在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。

在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

Java的并发采用的是共享内存模型

JVM的内存模型的理解

Java内存模型即Java Memory Model,简称JMM。JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。

Java线程之间的通信总是隐式进行,并且采用的是共享内存模型。这里提到的共享内存模型指的就是Java内存模型(简称JMM),JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

总之,JMM就是一组规则,这组规则意在解决在并发编程可能出现的线程安全问题,并提供了内置解决方案(happen-before原则)及其外部可使用的同步手段(synchronized/volatile等),确保了程序执行在多线程环境中的应有的原子性,可视性及其有序性。

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图

根据虚拟机规范,对于一个实例对象中的成员方法而言,如果方法中包含本地变量是基本数据类型(boolean,byte,short,char,int,long,float,double),将直接存储在工作内存的帧栈结构中,但倘若本地变量是引用类型,那么该变量的引用会存储在功能内存的帧栈中,而对象实例将存储在主内存(共享数据区域,堆)中。但对于实例对象的成员变量,不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,都会被存储到堆区。至于static变量以及类本身相关信息将会存储在主内存中。需要注意的是,在主内存中的实例对象可以被多线程共享,倘若两个线程同时调用了同一个对象的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存

硬件内存架构

正如上图所示,经过简化CPU与内存操作的简易图,实际上没有这么简单,这里为了理解方便,我们省去了南北桥并将三级缓存统一为CPU缓存(有些CPU只有二级缓存,有些CPU有三级缓存)。就目前计算机而言,一般拥有多个CPU并且每个CPU可能存在多个核心,多核是指在一枚处理器(CPU)中集成两个或多个完整的计算引擎(内核),这样就可以支持多任务并行执行,从多线程的调度来说,每个线程都会映射到各个CPU核心中并行运行。在CPU内部有一组CPU寄存器,寄存器是cpu直接访问和处理的数据,是一个临时放数据的空间。一般CPU都会从内存取数据到寄存器,然后进行处理,但由于内存的处理速度远远低于CPU,导致CPU在处理指令时往往花费很多时间在等待内存做准备工作,于是在寄存器和主内存间添加了CPU缓存,CPU缓存比较小,但访问速度比主内存快得多,如果CPU总是操作主内存中的同一址地的数据,很容易影响CPU执行速度,此时CPU缓存就可以把从内存提取的数据暂时保存起来,如果寄存器要取内存中同一位置的数据,直接从缓存中提取,无需直接从主内存取。需要注意的是,寄存器并不每次数据都可以从缓存中取得数据,万一不是同一个内存地址中的数据,那寄存器还必须直接绕过缓存从内存中取数据。所以并不每次都得到缓存中取数据,这种现象有个专业的名称叫做缓存的命中率,从缓存中取就命中,不从缓存中取从内存中取,就没命中,可见缓存命中率的高低也会影响CPU执行性能,这就是CPU、缓存以及主内存间的简要交互过程,总而言之当一个CPU需要访问主存时,会先读取一部分主存数据到CPU缓存(当然如果CPU缓存中存在需要的数据就会直接从缓存获取),进而在读取CPU缓存到寄存器,当CPU需要写数据到主存时,同样会先刷新寄存器中的数据到CPU缓存,然后再把数据刷新到主内存中。

Java线程与硬件处理器

Java线程的实现是基于一对一的线程模型,所谓的一对一模型,实际上就是通过语言级别层面程序去间接调用系统内核的线程模型,即我们在使用Java线程时,Java虚拟机内部是转而调用当前操作系统的内核线程来完成当前任务。这里需要了解一个术语,内核线程(Kernel-Level Thread,KLT),它是由操作系统内核(Kernel)支持的线程,这种线程是由操作系统内核来完成线程切换,内核通过操作调度器进而对线程执行调度,并将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这也就是操作系统可以同时处理多任务的原因。由于我们编写的多线程程序属于语言层面的,程序一般不会直接去调用内核线程,取而代之的是一种轻量级的进程(Light Weight Process),也是通常意义上的线程,由于每个轻量级进程都会映射到一个内核线程,因此我们可以通过轻量级进程调用内核线程,进而由操作系统内核将任务映射到各个处理器,这种轻量级进程与内核线程间1对1的关系就称为一对一的线程模型。如下图

如图所示,每个线程最终都会映射到CPU中进行处理,如果CPU存在多核,那么一个CPU将可以并行执行多个线程任务。

Java内存模型与硬件内存架构

多线程的执行最终都会映射到硬件处理器上进行执行,但Java内存模型和硬件内存架构并不完全一致。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(堆内存)之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因此总体上来说,Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。(注意对于Java内存区域划分也是同样的道理)

JMM存在的必要性

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,线程与主内存中的变量操作必须通过工作内存间接完成,主要过程是将变量从主内存拷贝的每个线程各自的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,如果存在两个线程同时对一个主内存中的实例对象的变量进行操作就有可能诱发线程安全问题。如下图,主内存中存在一个共享变量x,现在有A和B两条线程分别对该变量x=1进行操作,A/B线程各自的工作内存中存在共享变量副本x。假设现在A线程想要修改x的值为2,而B线程却想要读取x的值,那么B线程读取到的值是A线程更新后的值2还是更新前的值1呢?答案是,不确定,即B线程有可能读取到A线程更新前的值1,也有可能读取到A线程更新后的值2,这是因为工作内存是每个线程私有的数据区域,而线程A变量x时,首先是将变量从主内存拷贝到A线程的工作内存中,然后对变量进行操作,操作完成后再将变量x写回主内,而对于B线程的也是类似的,这样就有可能造成主内存与工作内存间数据存在一致性问题,假如A线程修改完后正在将数据写回主内存,而B线程此时正在读取主内存,即将x=1拷贝到自己的工作内存中,这样B线程读取到的值就是x=1,但如果A线程已将x=2写回主内存后,B线程才开始读取的话,那么此时B线程读取到的就是x=2,但到底是哪种情况先发生呢?这是不确定的,这也就是所谓的线程安全问题。

为了解决类似上述的问题,JVM定义了一组规则,通过这组规则来决定一个线程对共享变量的写入何时对另一个线程可见,这组规则也称为Java内存模型(即JMM),JMM是围绕着程序执行的原子性、有序性、可见性展开。

Java内存模型的承诺

原子性

原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。比如对于一个静态变量int x,两条线程同时对他赋值,线程A赋值为1,而线程B赋值为2,不管线程如何运行,最终x的值要么是1,要么是2,线程A和线程B间的操作是没有干扰的,这就是原子性操作,不可被中断的特点。有点要注意的是,对于32位系统的来说,long类型数据和double类型数据(对于基本数据类型,byte,short,int,float,boolean,char读写是原子操作),它们的读写并非原子性的,也就是说如果存在两条线程同时对long类型或者double类型的数据进行读写是存在相互干扰的,因为对于32位虚拟机来说,每次原子读写是32位的,而long和double则是64位的存储单元,这样会导致一个线程在写时,操作完前32位的原子操作后,轮到B线程读取时,恰好只读取到了后32位的数据,这样可能会读取到一个既非原值又不是线程修改值的变量,它可能是“半个变量”的数值,即64位数据被两个线程分成了两次读取。但也不必太担心,因为读取到“半个变量”的情况比较少见,至少在目前的商用的虚拟机中,几乎都把64位的数据的读写操作作为原子操作来执行,因此对于这个问题不必太在意,知道这么回事即可。

理解指令重排

计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,一般分以下3种

  • 编译器优化的重排

编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  • 指令并行的重排

现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序

  • 内存系统的重排

由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

其中编译器优化的重排属于编译期重排,指令并行的重排和内存系统的重排属于处理器重排,在多线程环境中,这些重排优化可能会导致程序出现内存可见性问题。

编译器重排 和 处理器指令重排

对于单线程而已指令重排几乎不会带来任何影响,比竟重排的前提是保证串行语义执行的一致性,但对于多线程环境而已,指令重排就可能导致严重的程序轮序执行问题,如下:

class MixedOrder{
    int a = 0;
    boolean flag = false;
    public void writer(){
        a = 1;
        flag = true;
    }

    public void read(){
        if(flag){
            int i = a + 1;
        }
    }
}

如上述代码,同时存在线程A和线程B对该实例对象进行操作,其中A线程调用写入方法,而B线程调用读取方法,由于指令重排等原因,可能导致程序执行顺序变为如下:

线程A                    线程B
 writer:                 read:
 1:flag = true;           1:flag = true;
 2:a = 1;                 2: a = 0 ; //误读
                          3: i = 1 ;

由于指令重排的原因,线程A的flag置为true被提前执行了,而a赋值为1的程序还未执行完,此时线程B,恰好读取flag的值为true,直接获取a的值(此时B线程并不知道a为0)并执行i赋值操作,结果i的值为1,而不是预期的2,这就是多线程环境下,指令重排导致的程序乱序执行的结果。因此,请记住,指令重排只会保证单线程中串行语义的执行的一致性,但并不会关心多线程间的语义一致性。

可见性

可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。但在多线程环境中可就不一定了,前面我们分析过,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题,另外指令重排以及编译器优化也可能导致可见性问题,通过前面的分析,我们知道无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程序轮序执行的问题,从而也就导致可见性问题。

有序性

有序性是指对于单线程的执行代码,我们总是认为代码的执行是按顺序依次执行的,这样的理解并没有毛病,毕竟对于单线程而言确实如此,但对于多线程环境,则可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致,要明白的是,在Java程序中,倘若在本线程内,所有操作都视为有序行为,如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的,前半句指的是单线程内保证串行语义执行的一致性,后半句则指指令重排现象和工作内存与主内存同步延迟现象。

如原子性问题,除了JVM自身提供的对基本数据类型读写操作的原子性外,对于方法级别或者代码块级别的原子性操作,可以使用synchronized关键字或者重入锁(ReentrantLock)保证程序执行的原子性。而工作内存与主内存同步延迟现象导致的可见性问题,可以使用synchronized关键字或者volatile关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见。对于指令重排导致的可见性问题和有序性问题,则可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止重排序优化。JMM内部还定义一套happens-before 原则来保证多线程环境下两个操作间的原子性、可见性以及有序性。

happens-before 原则

  • 程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。

  • 锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。

  • volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。

  • 线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见

  • 传递性 A先于B ,B先于C 那么A必然先于C

  • 线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。

  • 线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。

  • 对象终结规则 对象的构造函数执行,结束先于finalize()方法。

volatile内存语义

volatile是Java虚拟机提供的轻量级的同步机制。volatile关键字有如下两个作用

  • 保证被volatile修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总数可以被其他线程立即得知。

  • 禁止指令重排序优化。

volatile的可见性

关于volatile的可见性作用,我们必须意识到被volatile修饰的变量对所有线程总数立即可见的,对volatile变量的所有写操作总是能立刻反应到其他线程中,但是对于volatile变量运算操作在多线程环境并不保证安全性,如下

public class VolatileVisibility {
    public static volatile int i =0;

    public static void increase(){
        i++;
    }
}

正如上述代码所示,i变量的任何改变都会立马反应到其他线程中,但是如此存在多条线程同时调用increase()方法的话,就会出现线程安全问题,毕竟i++;操作并不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成,如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于increase方法必须使用synchronized修饰,以便保证线程安全,需要注意的是一旦使用synchronized修饰方法后,由于synchronized本身也具备与volatile相同的特性,即可见性,因此在这样种情况下就完全可以省去volatile修饰变量。

public class VolatileVisibility {
    public static int i =0;

    public synchronized static void increase(){
        i++;
    }
}

现在来看另外一种场景,可以使用volatile修饰变量达到线程安全的目的,如下

public class VolatileSafe {

    volatile boolean close;

    public void close(){
        close=true;
    }

    public void doWork(){
        while (!close){
            System.out.println("safe....");
        }
    }
}

由于对于boolean变量close值的修改属于原子性操作,因此可以通过使用volatile修饰变量close,使用该变量对其他线程立即可见,从而达到线程安全的目的。那么JMM是如何实现让volatile变量对其他线程立即可见的呢?实际上,当写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存中,当读取一个volatile变量时,JMM会把该线程对应的工作内存置为无效,那么该线程将只能从主内存中重新读取共享变量。volatile变量正是通过这种写-读方式实现对其他线程可见。

volatile禁止重排优化

volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象。内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。下面看一个非常典型的禁止重排优化的例子DCL,如下:

public class DoubleCheckLock {

    private static DoubleCheckLock instance;

    private DoubleCheckLock(){}

    public static DoubleCheckLock getInstance(){

        //第一次检测
        if (instance==null){
            //同步
            synchronized (DoubleCheckLock.class){
                if (instance == null){
                    //多线程环境下可能会出现问题的地方
                    instance = new DoubleCheckLock();
                }
            }
        }
        return instance;
    }
}

上述代码一个经典的单例的双重检测的代码,这段代码在单线程环境下并没有什么问题,但如果在多线程环境下就可以出现线程安全问题。原因在于某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。因为instance = new DoubleCheckLock();可以分为以下3步完成(伪代码)

memory = allocate(); //1.分配对象内存空间
instance(memory);    //2.初始化对象
instance = memory;   //3.设置instance指向刚分配的内存地址,此时instance!=null

由于步骤1和步骤2间可能会重排序,如下:

memory = allocate(); //1.分配对象内存空间
instance = memory;   //3.设置instance指向刚分配的内存地址,此时instance!=null,但是对象还没有初始化完成!
instance(memory);    //2.初始化对象

由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成,也就造成了线程安全问题。那么该如何解决呢,很简单,我们使用volatile禁止instance变量被执行指令重排优化即可。

//禁止指令重排优化
private volatile static DoubleCheckLock instance;

GC的原理和回收策略

回收算法

  • 标记回收算法(Mark and Sweep GC) 从"GC Roots"集合开始,将内存整个遍历一次,保留所有可以被GC Roots直接或间接引用到的对象,而剩下的对象都当作垃圾对待并回收,这个算法需要中断进程内其它组件的执行并且可能产生内存碎片。

  • 复制算法 (Copying) 将现有的内存空间分为两快,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。

  • 标记-压缩算法 (Mark-Compact) 先需要从根节点开始对所有可达对象做一次标记,但之后,它并不简单地清理未标记的对象,而是将所有的存活对象压缩到内存的一端。之后,清理边界外所有的空间。这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此,其性价比比较高。

  • 分代 将所有的新建对象都放入称为年轻代的内存区域,年轻代的特点是对象会很快回收,因此,在年轻代就选择效率较高的复制算法。当一个对象经过几次回收后依然存活,对象就会被放入称为老生代的内存空间。对于新生代适用于复制算法,而对于老年代则采取标记-压缩算法。

复制和标记-压缩算法的区别

乍一看这两个算法似乎并没有多大的区别,都是标记了然后挪到另外的内存地址进行回收,那为什么不同的分代要使用不同的回收算法呢?

其实2者最大的区别在于前者是用空间换时间后者则是用时间换空间。

乍一看这两个算法似乎并没有多大的区别,都是标记了然后挪到另外的内存地址进行回收,那为什么不同的分代要使用不同的回收算法呢?

其实2者最大的区别在于前者是用空间换时间后者则是用时间换空间。

前者的在工作的时候是不没有独立的“mark”与“copy”阶段的,而是合在一起做一个动作,就叫scavenge(或evacuate,或者就叫copy)。也就是说,每发现一个这次收集中尚未访问过的活对象就直接copy到新地方,同时设置forwarding pointer。这样的工作方式就需要多一份空间。

后者在工作的时候则需要分别的mark与compact阶段,mark阶段用来发现并标记所有活的对象,然后compact阶段才移动对象来达到compact的目的。如果compact方式是sliding compaction,则在mark之后就可以按顺序一个个对象“滑动”到空间的某一侧。因为已经先遍历了整个空间里的对象图,知道所有的活对象了,所以移动的时候就可以在同一个空间内而不需要多一份空间。

所以新生代的回收会更快一点,老年代的回收则会需要更长时间,同时压缩阶段是会暂停应用的,所以给我们应该尽量避免对象出现在老年代。

Dalvik虚拟机

java堆

Java堆实际上是由一个Active堆和一个Zygote堆组成的,其中,Zygote堆用来管理Zygote进程在启动过程中预加载和创建的各种对象,而Active堆是在Zygote进程fork第一个子进程之前创建的。以后启动的所有应用程序进程是被Zygote进程fork出来的,并都持有一个自己的Dalvik虚拟机。在创建应用程序的过程中,Dalvik虚拟机采用COW策略复制Zygote进程的地址空间。

COW策略:一开始的时候(未复制Zygote进程的地址空间的时候),应用程序进程和Zygote进程共享了同一个用来分配对象的堆。当Zygote进程或者应用程序进程对该堆进行写操作时,内核就会执行真正的拷贝操作,使得Zygote进程和应用程序进程分别拥有自己的一份拷贝,这就是所谓的COW。因为copy是十分耗时的,所以必须尽量避免copy或者尽量少的copy。

为了实现这个目的,当创建第一个应用程序进程时,会将已经使用了的那部分堆内存划分为一部分,还没有使用的堆内存划分为另外一部分。前者就称为Zygote堆,后者就称为Active堆。这样只需把zygote堆中的内容复制给应用程序进程就可以了。以后无论是Zygote进程,还是应用程序进程,当它们需要分配对象的时候,都在Active堆上进行。这样就可以使得Zygote堆尽可能少地被执行写操作,因而就可以减少执行写时拷贝的操作。在Zygote堆里面分配的对象其实主要就是Zygote进程在启动过程中预加载的类、资源和对象了。这意味着这些预加载的类、资源和对象可以在Zygote进程和应用程序进程中做到长期共享。这样既能减少拷贝操作,还能减少对内存的需求。

和GC有关的一些指标

在启动Dalvik虚拟机的时候,我们可以分别通过-Xms、-Xmx和-XX:HeapGrowthLimit三个选项来指定上述三个值,以上三个值分别表示表示

Starting Size : Dalvik虚拟机启动的时候,会先分配一块初始的堆内存给虚拟机使用。

Growth Limit:是系统给每一个程序的最大堆上限,超过这个上限,程序就会OOM。

Maximum Size:不受控情况下的最大堆内存大小,起始就是我们在用largeheap属性的时候,可以从系统获取的最大堆大小。

同时除了上面的这个三个指标外,还有几个指标也是值得我们关注的,那就是堆最小空闲值(Min Free)、堆最大空闲值(Max Free)和堆目标利用率(Target Utilization)。假设在某一次GC之后,存活对象占用内存的大小为LiveSize,那么这时候堆的理想大小应该为(LiveSize / U)。但是(LiveSize / U)必须大于等于(LiveSize + MinFree)并且小于等于(LiveSize + MaxFree),每次GC后垃圾回收器都会尽量让堆的利用率往目标利用率靠拢。所以当我们尝试手动去生成一些几百K的对象,试图去扩大可用堆大小的时候,反而会导致频繁的GC,因为这些对象的分配会导致GC,而GC后会让堆内存回到合适的比例,而我们使用的局部变量很快会被回收理论上存活对象还是那么多,我们的堆大小也会缩减回来无法达到扩充的目的。 与此同时这也是产生CONCURRENT GC的一个因素。

GC的类型

  • GC_FOR_MALLOC: 表示是在堆上分配对象时内存不足触发的GC。
  • GC_CONCURRENT: 当我们应用程序的堆内存达到一定量,或者可以理解为快要满的时候,系统会自动触发GC操作来释放内存。
  • GC_EXPLICIT: 表示是应用程序调用System.gc、VMRuntime.gc接口或者收到SIGUSR1信号时触发的GC。
  • GC_BEFORE_OOM: 表示是在准备抛OOM异常之前进行的最后努力而触发的GC。

实际上,GC_FOR_MALLOC、GC_CONCURRENT和GC_BEFORE_OOM三种类型的GC都是在分配对象的过程触发的。而并发和非并发GC的区别主要在于前者在GC过程中,有条件地挂起和唤醒非GC线程,而后者在执行GC的过程中,一直都是挂起非GC线程的。并行GC通过有条件地挂起和唤醒非GC线程,就可以使得应用程序获得更好的响应性。但是同时并行GC需要多执行一次标记根集对象以及递归标记那些在GC过程被访问了的对象的操作,所以也需要花费更多的CPU资源。

对象的分配和GC触发时机

  1. 调用函数dvmHeapSourceAlloc在Java堆上分配指定大小的内存。如果分配成功,那么就将分配得到的地址直接返回给调用者了。函数dvmHeapSourceAlloc在不改变Java堆当前大小的前提下进行内存分配,这是属于轻量级的内存分配动作。

  2. 如果上一步内存分配失败,这时候就需要执行一次GC了。不过如果GC线程已经在运行中,即gDvm.gcHeap->gcRunning的值等于true,那么就直接调用函数dvmWaitForConcurrentGcToComplete等到GC执行完成就是了。否则的话,就需要调用函数gcForMalloc来执行一次GC了,参数false表示不要回收软引用对象引用的对象。

  3. GC执行完毕后,再次调用函数dvmHeapSourceAlloc尝试轻量级的内存分配操作。如果分配成功,那么就将分配得到的地址直接返回给调用者了。

  4. 如果上一步内存分配失败,这时候就得考虑先将Java堆的当前大小设置为Dalvik虚拟机启动时指定的Java堆最大值,再进行内存分配了。这是通过调用函数dvmHeapSourceAllocAndGrow来实现的。

  5. 如果调用函数dvmHeapSourceAllocAndGrow分配内存成功,则直接将分配得到的地址直接返回给调用者了。

  6. 如果上一步内存分配还是失败,这时候就得出狠招了。再次调用函数gcForMalloc来执行GC。参数true表示要回收软引用对象引用的对象。

  7. GC执行完毕,再次调用函数dvmHeapSourceAllocAndGrow进行内存分配。这是最后一次努力了,成功与事都到此为止。

通过这个流程可以看到,在对象的分配中会导致GC,第一次分配对象失败我们会触发GC但是不回收Soft的引用,如果再次分配还是失败我们就会将Soft的内存也给回收,前者触发的GC是GC_FOR_MALLOC类型的GC,后者是GC_BEFORE_OOM类型的GC。而当内存分配成功后,我们会判断当前的内存占用是否是达到了GC_CONCURRENT的阀值,如果达到了那么又会触发GC_CONCURRENT。

那么这个阀值又是如何来的呢,上面我们说到的一个目标利用率,GC后我们会记录一个目标值,这个值理论上需要再上述的范围之内,如果不在我们会选取边界值做为目标值。虚拟机会记录这个目标值,当做当前允许总的可以分配到的内存。同时根据目标值减去固定值(200~500K),当做触发GC_CONCURRENT事件的阈值。

回收算法和内存碎片

主流的大部分Davik采取的都是标注与清理(Mark and Sweep)回收算法,也有实现了拷贝GC的,这一点和HotSpot是不一样的,具体使用什么算法是在编译期决定的,无法在运行的时候动态更换。如果在编译dalvik虚拟机的命令中指明了"WITH_COPYING_GC"选项,则编译"/dalvik/vm/alloc/Copying.cpp"源码 – 此是Android中拷贝GC算法的实现,否则编译"/dalvik/vm/alloc/HeapSource.cpp" – 其实现了标注与清理GC算法。

由于Mark and Sweep算法的缺点,容易导致内存碎片,所以在这个算法下,当我们有大量不连续小内存的时候,再分配一个较大对象时,还是会非常容易导致GC,比如我们在该手机上decode图片,具体情况如下:

所以对于Dalvik虚拟机的手机来说,我们首先要尽量避免掉频繁生成很多临时小变量(比如说:getView,onDraw等函数),另一个又要尽量去避免产生很多长生命周期的大对象。

ART内存回收机制

Java堆

ART运行时内部使用的Java堆的主要组成包括Image Space、Zygote Space、Allocation Space和Large Object Space四个Space,Image Space用来存在一些预加载的类, Zygote Space和Allocation Space与Dalvik虚拟机垃圾收集机制中的Zygote堆和Active堆的作用是一样的,

Large Object Space就是一些离散地址的集合,用来分配一些大对象从而提高了GC的管理效率和整体性能,类似如下图:

在下文的GC Log中,我们也能看到在art的GC Log中包含了LOS的信息,方便我们查看大内存的情况。

GC的类型

  • kGcCauseForAlloc ,当要分配内存的时候发现内存不够的情况下引起的GC,这种情况下的GC会stop world
  • kGcCauseBackground,当内存达到一定的阀值的时候会去出发GC,这个时候是一个后台gc,不会引起stop world
  • kGcCauseExplicit,显示调用的时候进行的gc,如果art打开了这个选项的情况下,在system.gc的时候会进行gc

对象的分配和GC触发时机

Art下内存分配和Dalvik下基本没有任何区别。

并发和非并发GC

Art在GC上不像Dalvik仅有一种回收算法,Art在不同的情况下会选择不同的回收算法,比如Alloc内存不够的时候会采用非并发GC,而在Alloc后发现内存达到一定阀值的时候又会触发并发GC。同时在前后台的情况下GC策略也不尽相同。

  • 非并发GC 步骤1. 调用子类实现的成员函数InitializePhase执行GC初始化阶段。

步骤2. 挂起所有的ART运行时线程。

步骤3. 调用子类实现的成员函数MarkingPhase执行GC标记阶段。

步骤4. 调用子类实现的成员函数ReclaimPhase执行GC回收阶段。

步骤5. 恢复第2步挂起的ART运行时线程。

步骤6. 调用子类实现的成员函数FinishPhase执行GC结束阶段。

  • 并发GC 步骤1. 调用子类实现的成员函数InitializePhase执行GC初始化阶段。

步骤2. 获取用于访问Java堆的锁。

步骤3. 调用子类实现的成员函数MarkingPhase执行GC并行标记阶段。

步骤4. 释放用于访问Java堆的锁。

步骤5. 挂起所有的ART运行时线程。

步骤6. 调用子类实现的成员函数HandleDirtyObjectsPhase处理在GC并行标记阶段被修改的对象。

步骤7. 恢复第4步挂起的ART运行时线程。

步骤8. 重复第5到第7步,直到所有在GC并行阶段被修改的对象都处理完成。

步骤9. 获取用于访问Java堆的锁。

步骤10. 调用子类实现的成员函数ReclaimPhase执行GC回收阶段。

步骤11. 释放用于访问Java堆的锁。

步骤12. 调用子类实现的成员函数FinishPhase执行GC结束阶段。

所以不论是并发还是非并发,都会引起stopworld的情况出现,并发的情况下单次stopworld的时间会更短。

Art并发和Dalvik并发GC的差异

Dalvik GC:

Art GC:

Art的并发GC和Dalvik的并发GC有什么区别呢,初看好像2者差不多,虽然没有一直挂起线程,但是也会有暂停线程去执行标记对象的流程。通过阅读相关文档可以了解到Art并发GC对于Dalvik来说主要有三个优势点:

  • 1、标记自身 Art在对象分配时会将新分配的对象压入到Heap类的成员变量allocation_stack_描述的Allocation Stack中去,从而可以一定程度缩减对象遍历范围。

  • 2、预读取 对于标记Allocation Stack的内存时,会预读取接下来要遍历的对象,同时再取出来该对象后又会将该对象引用的其他对象压入栈中,直至遍历完毕。

  • 3、减少Pause时间 在Mark阶段是不会Block其他线程的,这个阶段会有脏数据,比如Mark发现不会使用的但是这个时候又被其他线程使用的数据,在Mark阶段也会处理一些脏数据而不是留在最后Block的时候再去处理,这样也会减少后面Block阶段对于脏数据的处理的时间。

前后台GC

前台Foreground指的就是应用程序在前台运行时,而后台Background就是应用程序在后台运行时。因此,Foreground GC就是应用程序在前台运行时执行的GC,而Background就是应用程序在后台运行时执行的GC。

应用程序在前台运行时,响应性是最重要的,因此也要求执行的GC是高效的。相反,应用程序在后台运行时,响应性不是最重要的,这时候就适合用来解决堆的内存碎片问题。因此,Mark-Sweep GC适合作为Foreground GC,而Mark-Compact GC适合作为Background GC。

由于有Compact的能力存在,碎片化在Art上可以很好的被避免,这个也是Art一个很好的能力。

Art大法好

总的来看,art在gc上做的比dalvik好太多了,不光是gc的效率,减少pause时间,而且还在内存分配上对大内存的有单独的分配区域,同时还能有算法在后台做内存整理,减少内存碎片。对于开发者来说art下我们基本可以避免很多类似gc导致的卡顿问题了。另外根据谷歌自己的数据来看,Art相对Dalvik内存分配的效率提高了10倍,GC的效率提高了2-3倍。

GC Log

当我们想要根据GC日志来追查一些GC可能造成的卡顿时,我们需要了解GC日志的组成,不同信息代表了什么含义。

Dalvik GC日志

dalvik的日志格式基本如下:

D/dalvikvm: <GC_Reason> <Amount_freed>, <Heap_stats>, <Pause_time>, <Total_time>

gc_reason:就是我们上文提到的,是gc_alloc还是gc_concurrent,了解到不同的原因方便我们做不同的处理。

amount_freed:表示系统通过这次GC操作释放了多少内存

Heap_stats:中会显示当前内存的空闲比例以及使用情况(活动对象所占内存 / 当前程序总内存)

Pause_time:表示这次GC操作导致应用程序暂停的时间。关于这个暂停的时间,在2.3之前GC操作是不能并发进行的,也就是系统正在进行GC,那么应用程序就只能阻塞住等待GC结束。而自2.3之后,GC操作改成了并发的方式进行,就是说GC的过程中不会影响到应用程序的正常运行,但是在GC操作的开始和结束的时候会短暂阻塞一段时间,所以还有后续的一个total_time。

Total_time:表示本次GC所花费的总时间和上面的Pause_time,也就是stop all是不一样的,卡顿时间主要看上面的pause_time。

Art GC日志

I/art: <GC_Reason> <Amount_freed>, <LOS_Space_Status>, <Heap_stats>, <Pause_time>, <Total_time>

基本情况和Dalvik没有什么差别,GC的Reason更多了,还多了一个LOS_Space_Status

LOS_Space_Status:Large Object Space,大对象占用的空间,这部分内存并不是分配在堆上的,但仍属于应用程序内存空间,主要用来管理 bitmap 等占内存大的对象,避免因分配大内存导致堆频繁 GC。

Linux内存指标概念

VSS- Virtual Set Size 虚拟耗用内存(包含共享库占用的内存)RSS- Resident Set Size 实际使用物理内存(包含共享库占用的内存)PSS- Proportional Set Size 实际使用的物理内存(比例分配共享库占用的内存)USS- Unique Set Size 进程独自占用的物理内存(不包含共享库占用的内存)一般来说内存占用大小有如下规律:VSS >= RSS >= PSS >= USS

堆内存

dalvik.vm属性

dalvik.vm.heapstartsize=8m

相当于虚拟机的 -Xms配置,该项用来设置堆内存的初始大小。

dalvik.vm.heapgrowthlimit=192m

相当于虚拟机的 -XX:HeapGrowthLimit配置,该项用来设置一个标准的应用的最大堆内存大小,一个标准的应用就是没有使用android:largeHeap的应用。

dalvik.vm.heapsize=512m

相当于虚拟机的 -Xmx配置,该项设置了使用android:largeHeap的应用的最大堆内存大小,Runtime.getRuntime().maxMemory(),获取进程最大堆内存大小为 dalvik.vm.heapgrowthlimit和dalvik.vm.heapsize之一。d

alvik.vm.heapminfree=4m

dalvik.vm.heapminfree对应的是-XX:HeapMinFree配置,用来设置单次堆内存调整的最小值。

dalvik.vm.heapmaxfree=8m

dalvik.vm.heapmaxfree 对应的是-XX:HeapMaxFree配置,用来设置单次堆内存调整的最大值。

dalvik.vm.heaptargetutilization=0.75

相当于虚拟机的 -XX:HeapTargetUtilization,该项用来设置当前理想的堆内存利用率。其取值位于0与1之间。当GC进行完垃圾回收之后,Dalvik的堆内存会进行相应的调整,通常结果是当前存活的对象的大小与堆内存大小做除法,得到的值为这个选项的设置,即这里的0.75。

常用内存分析命令

dumpsys meminfo

cat /proc/meminfo

procrank

free

showmap

vmstat

OOM产生的场景

1.堆内存使用超过Runtime.getRuntime().maxMemory()

2.系统进程逻辑空间被占满导致OOM

3.超过系统的软限制,即proc/pid/fd下文件数目突破/proc/pid/limits中的限制

4.进程线程数有限制,华为很多机型为500个

监控数据

内存使用曲线

每隔一段时间获取堆内存和进程内存的使用情况,从而获得随使用时间而变化的内存曲线。

通过Runtime获取堆内存数据

系统内存数据

进程内存数据信息

监控不良使用

Activity泄漏检测

知道对象的生命周期后对对象的GC的结果进行检测,Java中只有弱引用 引用的的对象会在GC时被回收,同时垃圾收集器会把引用加入ReferenceQueue,制定一定策略判断是否可能有内存泄漏,通过dump heap进行分析,可以确定是否真的泄漏和泄漏的对象的被引用的路径。

Bitmap使用

使用统一的BitMap工厂。

低内存检测

物理内存不足

根据LowMemoryKiller在系统低内存时开始清理后台进程时,系统会调用后台进程的onLowMemory 的回调或者 onTrimMemory 回调且不同的标记位,加上 ActivityManager.MemoryInfo 的 lowMemory 标记来获得低内存的发生时机。

虚拟内存不足

超过虚拟内存的大小限制则直接触发 OOM 异常,当虚拟内存占用超过最大限制的 90% 时,认为进程低内存。

已用逻辑空间地址可以查看/proc/pid/status中的VmSize记录

超过系统软限制

ulimit -a 可以查看linux系统的软限制

/proc/pid/limits 描述着linux系统对对应进程的限制

/proc/sys/kernel中描述的限制

规定了每个进程创建线程数目的上限。

OutOfMemoryError时记录/proc/pid目录下的如下信息:

查看/proc/pid/fd来监控app打开文件的情况,

查看 /proc/pid/task来监控线程使用情况,

OOM的堆栈信息

业务场景数据

重载Finalize引发的内存泄露

一些同学在开发JNI的时候,通常会使用finalize来做native内存的释放,而在频繁的列表滑动创建和回收持有native内存的Java对象(简称NativeBean,后同)后,我们去使用内存分析工具,比如zprofiler、mat等,分析一些oom的heap时,经常能看到 java.lang.ref.FinalizerReference占用很大数量的NativeBean,而其实你并没有这么多数据项,那么这里面的原因是什么呢?

FinalizerReference

通过MAT这些工具你会发现,我们的NativeBean都被FinalizerReference持有者,这个FinalizerReference主要目的就是为了协助FinalizerDaemon守护线程完成对象的finalize工作而生的。

public final class FinalizerReference<T> extends Reference<T> {
    // This queue contains those objects eligible for finalization. public static final ReferenceQueue queue = new ReferenceQueue();

   ..............
   ..............

    // When the GC wants something finalized, it moves it from the 'referent' field to // the 'zombie' field instead. private T zombie;

    public FinalizerReference(T r, ReferenceQueue<? super T> q) {
        super(r, q);
    }

    @Override public T get() {
        return zombie;
    }

    @Override public void clear() {
        zombie = null;
    }

    public static void add(Object referent) {
        FinalizerReference<?> reference = new FinalizerReference(referent, queue);
        synchronized (LIST_LOCK) {
            reference.prev = null;
            reference.next = head;
            if (head != null) {
                head.prev = reference;
            }
            head = reference;
        }
    }

    public static void remove(FinalizerReference<?> reference) {
        synchronized (LIST_LOCK) {
            FinalizerReference<?> next = reference.next;
            FinalizerReference<?> prev = reference.prev;
            reference.next = null;
            reference.prev = null;
            if (prev != null) {
                prev.next = next;
            } else {
                head = next;
            }
            if (next != null) {
                next.prev = prev;
            }
        }
    }
}

这个类 提供了2个很重要的方法,add是将对象插入到ReferenceQueue中,而remove则是从中移出,而这个ReferenceQueue源码比较简单,大家可以自行去了解一下,其实可以简单理解是引用的队列,那么我们重点要了解清楚的是这些引用是什么时机添加到ReferenceQueue,什么时机又从中移出呢?

Add和Remove的时机

在我们了解时机之前,我们先学习一些基本概念,什么是finalizer类呢?其实我们知道类的修饰有很多,比如final,abstract,public等,如果某个类用final修饰,我们就说这个类是final类,上面列的都是语法层面我们可以显式指定的,在JVM里其实还会给类标记一些其他符号,比如finalizer,表示这个类是一个finalizer类(为了和java.lang.ref.Finalizer类区分,下文在提到的带finalizer标识的类时会简称为f类),GC在处理这种类的对象时要做一些特殊的处理,如在这个对象被回收之前会调用它的finalize方法。

也就是说重载了finalize函数,并且非空实现的类就是咱们所说的f类,接下来我们就聊聊add的时机。

了解LeakCanary原理的同学,对这个机制应该比较了解,当WeakReference 创建时,传入一个 ReferenceQueue 对象,当被 WeakReference 引用的对象的生命周期结束,一旦被 GC 检查到,GC 将会把该对象添加到 ReferenceQueue 中。那么对于FinalizerReference来说,他其实也是类似的,这个add方法是从虚拟机中反调回来的,当GC发生时queue中就会插入当前正准备释放内存的对象的f类引用。

那么f类又是在什么时机从ReferenceQueue中移出的呢?

public final class Daemons {

    ...
        private static class FinalizerDaemon extends Daemon {
        private static final FinalizerDaemon INSTANCE = new FinalizerDaemon();
        private final ReferenceQueue<Object> queue = FinalizerReference.queue;
        private volatile Object finalizingObject;
        private volatile long finalizingStartedNanos;

        FinalizerDaemon() {
            super("FinalizerDaemon");
        }

        @Override public void run() {
            while (isRunning()) {
                // Take a reference, blocking until one is ready or the thread should stop try {
                    doFinalize((FinalizerReference<?>) queue.remove());
                } catch (InterruptedException ignored) {
                }
            }
        }

        @FindBugsSuppressWarnings("FI_EXPLICIT_INVOCATION")
        private void doFinalize(FinalizerReference<?> reference) {
            FinalizerReference.remove(reference);
            Object object = reference.get();
            reference.clear();
            try {
                finalizingStartedNanos = System.nanoTime();
                finalizingObject = object;
                synchronized (FinalizerWatchdogDaemon.INSTANCE) {
                    FinalizerWatchdogDaemon.INSTANCE.notify();
                }
                object.finalize();
            } catch (Throwable ex) {
                // The RI silently swallows these, but Android has always logged. System.logE("Uncaught exception thrown by finalizer", ex);
            } finally {
                // Done finalizing, stop holding the object as live.
                finalizingObject = null;
            }
        }
    }

    ...
}

FinalizerDaemon是Daemons.java中定义的另一个守护线程,FinalizerReference中定义的queue的消费者就是它。它内部定义了一个ReferenceQueue类型的对象queue,并将其赋值为前面说的FinalizerReference中的定义的那个queue。run方法中通过ReferenceQueue的remove方法把保存在queue中的Reference获取出来并通过doFinalize方法来调用f类的finalize方法,这里我们就了解到了Remove时机,不过我这里还多说一点,说说一个我们遇到的finalize timed out异常的原理。

通过查看ReferenceQueue的源码可知,ReferenceQueue的remove方法是阻塞的,在队列中没有Reference时将阻塞直到有Reference入队。我们看一下doFinalize方法,通过从队列中获取出来的reference的get方法获取到被引用的真实对象,并在这里调用该对象的finalize方法。但在这之前会通过FinalizerWatchdogDaemon.INSTANCE.notify()唤醒FinalizerWatchdogDaemon守护线程,而这个FinalizerWatchdogDaemon它主要用来监控finalize方法执行的时长,并在finalize执行超时时会抛出,所以我们不要在finalize方法中做耗时操作。

f类的使用不当造成的影响

首先当然是我们要解决的内存泄露,由于Daemons中的几个都是守护线程,我们看到它会创建一个,这个线程的优先级并不是最高的,意味着在CPU很紧张的情况下其被调度的优先级可能会受到影响,所以当你在频繁创建f类对象时,他没有办法及时被回收,造成内存泄露。

比如当你在adapter中的getview去创建这个f类的时候,而当f类要被回收时他会首先加入到ReferenceQueue中,当你不断滑动列表去绘制,CPU资源紧张的情况下,这个守护线程没有被调度去消费这些存在ReferenceQueue中的f类,这样就有可能造成内存泄露。

与此同时由于第一次GC的时候会将对象加入到ReferenceQueue中来,导致f类的回收至少需要2次GC才能被回收,而守护线程的优先级低,很可能长时间没被回收,从而容易导致f类在资源紧张时进入到老年代,从而引起full gc造成卡顿。

解决策略

尽量不要重载finalize方法,而是通过自己业务的监控或者手动接口去释放内存,如果一定要使用,那么一定不要让这些频繁创建的对象,或者大对象通过finalize来释放,finalize最好是作为最后的保证。

如果一定要使用finalize方法,要记得调用super.finalize。

对象引用

在某些情况下,我们希望有些对象不需要立刻回收或者说从全局的角度来说并没有立刻回收的必要性。比如缓存系统的设计,在内存不吃紧或者说为了提高运行效率的情况下,一些暂时不用的对象仍然可放置在内存中,而不是立刻进行回收。因此,从jdk1.2 版本开始,java设计人员把对象的引用细分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)四种级别,主要区别体现在在被GC回收的优先级上:

强引用->软引用->弱引用->虚引用。

也就是说从jdk 1.2开始,垃圾回收器回收对象时,对象的可达性分析需要考虑考虑对象的引用强度,也就是说现在对象的有效性=可达性+引用类型。先来看下类层次结构如下:

1. 引用的四种类型

  1. 强引用(Strong Reference) 在代码中普遍使用的,类似Person person=new Person();如果一个对象具有强引用,则无论在什么情况下,GC都不会回收被引用的对象。当内存空间不足时,JAVA虚拟机宁可抛出OutOfMemoryError终止应用程序也不会回收具有强引用的对象。

  2. 软引用(Soft Reference) 表示一个对象处在有用但非必须的状态。如果一个对象具有软引用,在内存空间充足时,GC就不会回收该对象;当内存空间不足时,GC会回收该对象的内存(回收发生在OutOfMemoryError之前)。

Person person=new Person();
SoftReference sr=new SoftReference(person);

软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被GC回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中,以便在恰当的时候将该软引用回收。但是由于GC线程的优先级较低,通常手动调用System.gc()并不能立即执行GC,因此弱引用所引用的对象并不一定会被马上回收。

  1. 弱引用(Weak Reference) 用来描述非必须的对象。它类似软引用,但是强度比软引用更弱一些:弱引用具有更短的生命.GC在扫描的过程中,一旦发现只具有被弱引用关联的对象,都会回收掉被弱引用关联的对象。换言之,无论当前内存是否紧缺,GC都将回收被弱引用关联的对象。
Person person=new Person();
WeakReference wr=new WeakReference(person);

同样弱引用也可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被GC回收了,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中,以便在恰当的时候将该弱引用回收。

  1. 虚引用(Phantom Reference) 虚引等同于没有引用,这意味着在任何时候都可能被GC回收,设置虚引用的目的是为了被虚引用关联的对象在被垃圾回收器回收时,能够收到一个系统通知。(被用来跟踪对象被GC回收的活动)虚引用和弱引用的区别在于:虚引用在使用时必须和引用队列(ReferenceQueue)联合使用,其在GC回收期间的活动如下:
ReferenceQueue queue=new ReferenceQueue();
PhantomReference pr=new PhantomReference(object,queue);

也即是GC在回收一个对象时,如果发现该对象具有虚引用,那么在回收之前会首先该对象的虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入虚引用来了解被引用的对象是否被GC回收。

ReferenceQueue和Reference

  1. ReferenceQueue含义及作用 通常我们将其ReferenceQueue翻译为引用队列,换言之就是存放引用的队列,保存的是Reference对象。其作用在于Reference对象所引用的对象被GC回收时,该Reference对象将会被加入引用队列中(ReferenceQueue)的队列末尾,这相当于是一种通知机制.当关联的引用队列中有数据的时候,意味着引用指向的堆内存中的对象被回收。通过这种方式,JVM允许我们在对象被销毁后,做一些我们自己想做的事情。JVM提供了一个ReferenceHandler线程,将引用加入到注册的引用队列中

ReferenceQueue常用的方法:

public Reference<? extends T> poll():从队列中取出一个元素,队列为空则返回null;

public Reference<? extends T> remove():从队列中出对一个元素,若没有则阻塞至有可出队元素;

public Reference<? extends T> remove(long timeout):从队列中出对一个元素,若没有则阻塞至有可出对元素或阻塞至超过timeout毫秒;

见如下代码:

ReferenceQueue< Person> rq=new ReferenceQueue<Person>();
Person person=new Person();
SoftReference sr=new SoftReference(person,rq);

这段代码中,对于Person对象有两种引用类型,一是person的强引用,而是sr的软引用。sr强引用了SoftReference对象,该对象软引用了Person对象。当person被回收时,sr所强引用的对象将会被放到rq的队列末尾。利用ReferenceQueue可以清除失去了软引用对象的SoftReference,如下操作:

SoftReference ref=null;
while((ref=(Person)rq.poll())!=null){
    //清除
}
  1. Reference类

Reference是SoftReference,WeakReference,PhantomReference类的父类,其内部通过一个next字段来构建了一个Reference类型的单向列表,而queue字段存放了引用对象对应的引用队列,若在Reference的子类构造函数中没有指定,则默认关联一个ReferenceQueue.NULL队列。

  1. 四种引用类型使用场景

强引用类型是在代码中普遍存在,无须解释太多了

软引用和弱引用:两者都可以实现缓存功能,但软引用实现的缓存通常用在服务端,而在移动设备中的内存更为紧缺,对垃圾回收更为敏感,因此android中的缓存通常是用弱引用来实现(比如LruCache)

在开发中有这么一个场景:用户信息查询。在不考虑对用户信息更改的情况下,通常有以下两种方案来实现:

  1. 每次查询时,连接数据库获取信息,缺点是IO读频繁,平均响应时间较长。优点是内存占用少。

  2. 第一次查询时,读取数据库后将用户信息存放在内存中,以后每次查询从内存中读取,优点是读取速度快,在请求次数较少的情况下,内存占用较多。

现在我们用采用第二种方案,基于缓存来设计,代码如下:

User.java

public class User {
    private String id;
    private String name;
    private int age;

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }

    @Override
    public String toString() {
        return "User{" +
                "id='" + id + '\'' +
                ", name='" + name + '\'' +
                ", age=" + age +
                '}';
    }
}

UserCache.java

public class UserCache {
    private static UserCache cache;

    private Hashtable<String,UserRef> userRefs;

    private ReferenceQueue<User> q;

    private class UserRef extends WeakReference<User> {
        private String key="";

        public UserRef(User referent, ReferenceQueue<? super User> q) {
            super(referent, q);
            key=referent.getId();
        }
    }

    private UserCache() {
        userRefs=new Hashtable<>();
        q=new ReferenceQueue<>();
    }

    public static UserCache getInstance() {
        synchronized (UserCache.class) {
            if (cache==null) {
                synchronized (UserCache.class) {
                    cache=new UserCache();
                }
            }
        }
        return cache;
    }

    private void cacheObject(User user) {
        cleanCache();
        UserRef userRef = new UserRef(user, q);
        userRefs.put(user.getId(),userRef);
    }

    public User getObject(String id){
        User user=null;
        if (userRefs.contains(id)) {
            System.err.println("get data from cache");
            UserRef ref = userRefs.get(id);
            user=ref.get();
        }
        if (user==null) {
            System.err.println("get data from db");
            user=new User(id);
            user.setName("zpw"+new Random().nextInt(10));
            cacheObject(user);
        }
        return user;
    }

    private void cleanCache() {
        UserRef ref;
        while ((ref= (UserRef) q.poll())!=null){
            userRefs.remove(ref.key);
        }
    }

    public void cleanAll() {
        cleanCache();
        userRefs.clear();
        System.gc();
        System.runFinalization();
    }
}

UserCacheTest.java

public static void main(String[] args) {
    UserCache cache=UserCache.getInstance();

    for (int i = 0; i < 2; i++) {
        System.out.println(cache.getObject("1"));
    }

    cache.cleanAll();
    System.out.println(cache.getObject("1"));
}
get data from db
User{id='1', name='zpw7', age=0}
User{id='1', name='zpw0', age=0}
get data from db
User{id='1', name='zpw1', age=0}
get data from db

如果一个对象有多个引用类型,那在进行垃圾回收时如何判断对象的可达性呢?其原则如下:

单条引用链的可达性以最弱的一个引用类型来决定;

多条引用链的可达性以最强的一个引用类型来决定;

举例说明,如下图所示:

对Object 2进行分析,路径1-1——>2-1中取最弱的引用,软引用;路径1-2——>2-2中取最弱引用,虚引用;在这两条路径中取最强引用,软引用。因此Object 2是最终的引用类型是软引用。

private static void test_gc1(){
    //在heap中创建内容为"wohenhao"的对象,并建立a到该对象的强引用,此时该对象时强可及
    String a=new String("wohenhao");
    //对heap中的对象建立软引用,此时heap中的对象仍然是强可及
    SoftReference< ?> softReference=new SoftReference<String>(a);
    //对heap中的对象建立弱引用,此时heap中的对象仍然是强可及
    WeakReference< ?> weakReference=new WeakReference<String>(a);
    System.out.println("强引用:"+a+"\n软引用"+softReference.get()+"\n弱引用"+weakReference.get()+"\n");



    //heap中的对象从强可及到软可及
    a=null;
    System.out.println("强引用:"+a+"\n软引用"+softReference.get()+"\n弱引用"+weakReference.get()+"\n");



    softReference.clear();//heap中对象从软可及变成弱可及,此时调用System.gc(),
    System.gc();
    System.out.println("强引用:"+a+"\n软引用"+softReference.get()+"\n弱引用"+weakReference.get()+"\n");
}
强引用:wohenhao
软引用wohenhao
弱引用wohenhao

强引用:null
软引用wohenhao
弱引用wohenhao

强引用:null
软引用null
弱引用null
private static void test_gc2(){
    //在heap中创建内容为"wohenhao"的对象,并建立a到该对象的强引用,此时该对象时强可及
    String a=new String("wohenhao");
    //对heap中的对象建立软引用,此时heap中的对象仍然是强可及
    SoftReference< ?> softReference=new SoftReference<String>(a);
    //对heap中的对象建立弱引用,此时heap中的对象仍然是强可及
    WeakReference< ?> weakReference=new WeakReference<String>(a);
    System.out.println("强引用:"+a+"\n软引用"+softReference.get()+"\n弱引用"+weakReference.get()+"\n");



    a=null;//heap中的对象从强可及到软可及
    System.out.println("强引用:"+a+"\n软引用"+softReference.get()+"\n弱引用"+weakReference.get()+"\n");



    System.gc();
    System.out.println("强引用:"+a+"\n软引用"+softReference.get()+"\n弱引用"+weakReference.get()+"\n");
}
强引用:wohenhao
软引用wohenhao
弱引用wohenhao

强引用:null
软引用wohenhao
弱引用wohenhao

强引用:null
软引用wohenhao
弱引用wohenhao