Android进阶学习收获(1~6节)

第一节《程序运行时,内存到底是如何分配的》

    Java虚拟机在执行Java程序时,会把他管理的内存区域划分为不同的数据区域,下面这张图描述了一个HelloWorld.java文件被JVM加载到内存的过程:

  1. HelloWorld.java文件首先经过编译器编译,生成HelloWorld.class 字节码文件。
  2. 当Java程序访问这个类时,需要通过ClassLoader将HelloWorld.class字节码文件加载到JVM的内存中。
  3. JVM的内存可以划分为若干个不同的数据区域,主要分为:程序计数器、虚拟机栈、本地方法栈、堆、方法区。

下面分别解释下五个不同的数据区域的用途:

  1. 程序计数器:是虚拟机中一块较小的内存空间,主要用于记录当前线程执行的位置。Java是多线程的,CPU通过给多个线程分配时间片来执行任务,当某个线程被CPU挂起时,需要记录代码执行的位置,方便CPU重新执行此线程时,知道从哪行指令开始执行。以下几点需要格外注意:
        1)在Java虚拟机规范中,只有对程序计数器这一区域没有规定任何OutOfMemoryError情况。
        2)其属于线程私有,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
        3)当一个线程正在执行一个方法时,这个计数器记录的是正在执行的虚拟字节码指令的地址,如果正在执行的是Native方            法,这个计数器值为空(Undefined)。
  2. 虚拟机栈:也是线程私有,生命周期与线程同步。虚拟机栈的初衷是用来描述Java方法执行的内存模型,每个方法执行的时候,JVM都会在虚拟机中创建一个栈帧。栈帧是用来支持虚拟机进行方法调用和方法执行的数据结构,每一个线程在执行某个方法时,都会为这个方法创建一个栈帧。每个栈帧又包含这些内容:
    1)局部变量表:用来存储变量值。在编译之后,局部变量表的长度已经确定了。
    2)操作数栈:一个后入先出栈(LIFO),其最大深度也是在编译的时候写入方法的Code属性表中的max_stacks数据项中。        当一个方法刚开始执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指令被压入和弹出操作数栈(比如iadd指令就是将操作数栈中栈顶的两个元素弹出,执行加法运算,并将结果重新压回到操作数栈中)
    3)动态链接:其主要目的就是为了支持方法调用过程中的动态连接。
    4)返回地址:一个方法执行后,无论它是正常退出还是异常退出,其都需要返回到方法被调用的位置,程序才能继续执行。而虚拟机栈中的“返回地址”就是用来帮助当前方法恢复它的上层方法执行状态。
  3. 本地方法栈:本地方法栈和上面介绍的虚拟机栈基本相同,只不过针对本地(Native)方法。开发中如果涉及JNI,可能接触的本地方法栈会多一些,在有些虚拟机的实现中已经将两个合二为一了。
  4. 堆(Heap):是JVM所管理的内存中最大的一块,该区域唯一的目的就是存放对象实例,几乎所有的对象实例都在堆里分配。按照对象的存储时间的不同,堆中的内存可以划分为新生代(Young)和老年代(Old),其中新生代又被划分为Eden和Survivor区。
  5. 方法区:是JVM规范里规定的一块运行时数据区,其主要存储已经被JVM加载的类的信息(版本、字段、方法、接口)、常量、静态变量、即时编译器编译后的代码和数据。该区域同堆一样,也是被各个线程共享的内存区域。关于方法区和永久区,开发者对两者的概念常常混为一谈,先对其做下对比:
    1)方法区是JVM规范中规定的一块区域,但是并不是实际实现,切记将规范跟实现混为一谈,不同的JVM厂商可以有不同版本的“方法区”实现。
    2)HotSpot在JDK1.7以前使用“永久区”(或者叫Perm区)来实现方法区,在JDK1.8以后“永久区”就已经被移出了,取而代之的是一个叫作“元空间”的实现方式。
    3)总结:方法区是规范层面的东西,规定了这个区域要存放哪些数据。永久区或者元空间是对方法区的不同实现,是实现层面的东西。

总结:对于JVM运行时的内存布局,我们要始终记住一点:上面介绍的5块内容都是在Java虚拟机规范中定义的规则,这些规则只是描述了各个区域是负责做什么事情、存储什么样的数据、如何处理异常、是否允许线程共享等。千万不要将他们理解为虚拟机的具体实现,虚拟机的具体实现有很多,比如Sun公司的HotSpot、JRocket、IBM J9、以及我们非常熟悉的Android Dalvik 和ART等。这些具体实现在符合上面5种运行时数据区的前提下,又各自有不同的实现方式。用一张图可以概括下本章内容:

第二节:GC回收机制与分代回收策略

     Java语言开发者比C语言开发者幸福的地方就在于,我们不需要手动释放对象的内存,JVM的垃圾回收器(Garbage Collector)会为我们自动回收。但幸福是有代价的,一旦这种自动化机制出错,我们又不得不深入理解GC回收机制,甚至需要对这些自动化的技术实施必要的监控和调节。

      Java运行时区域的各部分中,程序计数器、虚拟机栈、本地方法栈这3个区域随线程而生,随线程自灭;栈中的栈帧随着方法的进入和退出有条不紊的执行着出栈和入栈操作;这几个区域不需要考虑回收的问题。而堆和方法区则不一样,一个接口的实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的就是这部分内存。

怎么判断哪些对象是垃圾?Java虚拟机使用一种叫作“可达性分析”的算法来决定对象是否可以被回收。可达性分析算法是从离散数据中的图论引入的,JVM把内存中所有的对象之间的引用关系看做一张图,通过一组名为“GC Root”的对象作为起始点,从这些节点开始向下搜索,搜索所走的路径成为引用连,最后通过判断对象的引用链是否可达来决定对象是否可被回收。如下图所示:

注意:上图中圆形图标虽然标记的是对象,但实际上代表的是此对象在内存中的引用。包括GC Root也是一组引用而非对象。

在Java中,有哪几种对象可以作为GC Root:

  1. Java虚拟机栈(局部变量表)中的引用的对象。
  2. 方法区中静态引用指向的对象。
  3. 仍处于存活状态中的线程对象。
  4. Native方法中JNI引用的对象。

什么时候回收?不同的虚拟机实现有着不同的GC实现机制,但是一般情况下每一种GC实现都会在以下两种情况下触发垃圾回收。

  1. Allocation Failure:在堆内存中分配时,如果因为剩余空间不足导致对象内存分配失败,这时会触发一次GC。
  2. System.gc():在应用层,Java开发工程师可以主动调用此API来请求一次GC。

-Xms初始分配JVM运行时的内存大小,如果不指定,默认为物理内存的1/64。比如我们运行如下命令执行HelloWorld程序,从物理内存中分配出200M空间给JVM内存。

java -Xms200m HelloWorld

测试虚拟机栈中局部变量引用的对象作为GC Root:

public class TestGc {

    private int _10Mb = 10 * 1024 * 1024;
    private byte[] memory = new byte[8 * _10Mb];

    public static void main(String[] args) {
        System.out.println("开始时:");
        printMemory();
        method();
        System.gc();
        System.out.println("第二次GC结束");
        printMemory();
    }

    public static void method() {
        TestGc testGc = new TestGc();
        System.gc();
        System.out.println("第一次GC结束");
        printMemory();
    }

    public static void printMemory() {
        System.out.print("free is " + Runtime.getRuntime().freeMemory() / 1024 / 1024 + "M,");
        System.out.println("total is " + Runtime.getRuntime().totalMemory() / 1024 / 1024 + "M");
    }
}

测试结果如下:

testGc变量作为局部变量,引用了new出来的对象(80M),作为GC Root,在GC后并不会被回收,method方法执行完后,局部变量随之消失,不再有局部变量指向这个80M对象,再次GC操作时,此80M对象会被回收。

注意:全局变量和静态变量不同,它不会当做GC Root。

如何回收垃圾?垃圾收集算法的实现涉及大量的程序细节,各家虚拟机厂商对其实现细节各有不同,下面介绍几种算法的思想以及优缺点:

  1. 标记清除算法(Mark and Sweep GC):从“GC Roots”集合开始,将内存整个遍历一次,保留所有可以被GC Roots直接或者间接引用到的对象,而剩下的对象都当做垃圾对待并回收,过程分两步。
          1)Mark标记阶段:找到内存中的所有GC Root对象,只要是和GC Root对象直接或者间接相连的则标记为灰色(也就是存活对              象),否则标记为黑色(也就是垃圾对象)。
           2)Sweep清除阶段:当遍历完所有的GC Root之后,则将标记为垃圾的对象直接清除。
           优点:实现简单,不需要将对象进行移动。
           缺点:这个算法需要中断进程内其他组件的执行,并且可能产生内存碎片,增加了垃圾回收的频率。
  2. 复制算法(Coping):将现有的内存分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中存活的对象复制到未被使用的内存块中。之后,清除正在使用的内存块中使用的对象,交换两个内存的角色,完成垃圾回收。
          优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片。
          缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制。
  3. 标记-压缩算法(Mark-Compact):需要先从根节点对所有可达对象做一次标记,之后,它并不简单滴清理未标记的对象,而是将所有的存活对象压缩到内存的一端。最后,清理边界外所有的空间,因此,标记压缩也可分两步完成:
        1)Mark标记阶段:找到内存中的所有GC Root对象,只要是和GC Root对象直接或者间接相连则标记为灰色(即存活对象),否则标记为黑色(即垃圾对象)。
        2)Compact压缩阶段:将剩余存活对象按顺序压缩到内存的某一端。
        优点:这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此性价比较高。
        缺点:所谓压缩操作,仍需进行局部对象移动,所以一定程度上还是降低了效率。

JVM分代回收策略:

        Java虚拟机根据对象存活的周期不同,把堆内存划分为几块,一般分为新生代、老年代,这就是JVM的内存分配策略。

分代回收的中心思想是:对于新创建的对象在新生代中分配内存,此区域内的声明周期一般较短。如果经过多次回收仍然存活下来,则将他们转移到老年代中。

年轻代(Young Generation):

      新生成的对象优先存放在新生代中,新生代对象朝生夕死,存活率很低,在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,回收率很高。新生代中因为要进行一些复制操作,所以一般采用的GC回收算法是复制算法。

     新生代又可以继续细分为3部分:Eden、Survivor0(简称S0)、Survivor1(简称S1)。这三部分按照8:1:1的比例来划分新生代,其分配过程如下:绝大多数刚刚被创建的对象会放在Eden区,当Eden区第一次满的时候,会进行垃圾回收。首先将Eden区的垃圾对象进行清除,并将存活的对象复制到S0,此时S1是空的,如图:

下一次Eden区满时,再执行一次垃圾回收,此时会将Eden和S0区中所有的垃圾对象清除,并将存活对象复制到S1,此时S0变为空。如此反复在S0和S1之间切换几次(默认15次)之后,如果还有存活现象,说明这些对象的生命周期较长,则将他们移入到老年代中。

老年代(Old Generation):

      一个对象如果在新生代存活了足够长的时间而没有被清理掉,则会被复制到老年代。老年代的内存大小一般比新生代大,能存放更多的对象。如果对象比较大(比如长字符串或者大数组),并且新生代的剩余空间不足,则这个大对象会直接被分派到老年代上。老年代因为对象的声明周期较长,不需要过多的复制操作,所以一般采用标记压缩的回收算法。

注意:对于老年代可能存在这么一种情况,老年代中的对象有时候会引用到新生代对象。这时,如果要执行新生代GC,则可能需要查询整个老年代上可能引用新生代的情况,这显然是低效的。所以,老年代中维护了一个512 byte 的card table,所有老年代对象引用新生代对象的信息都记录在这里。每当新生代发生GC时,只需要检查这个card table即可,大大提高了性能。

GC  Log 分析

     为了让上层应用开发人员更加方便的调试Java程序,JVM提供了相应的GC日志。在GC执行垃圾回收事件的过程中,会有各种相应的Log被打印出来,其中新生代和老年代所打印的日志是有区别的。

  • 新生代GC:这一区域的GC叫做 Minor(未成年的、较小的) GC。因为Java对象大多都具备朝生昔灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
  • 老年代GC:发生在这一区域的GC也叫做Major GC 或者Full GC。当出现Major GC,经常会伴随至少一次的Minor GC。(有些虚拟机汇中Major GC 和Full GC还是有区别的,Major GC只是代表回收老年代的内存,而Full GC则代表回收整个堆中的内存,也就是新生代+老年代)

JVM四种引用关系如下表所示:

第三节:字节码层面分析class类文件结构

class的来龙去脉:

      Java能够实现“一次编译,到处运行”,这其中class文件要占大部分功劳。为了让Java语言具有良好的跨平台能力,Java独具匠心的提供了一种可以在所有平台都能使用的一种中间代码--字节码类文件(.class文件),有了字节码,无论是哪种平台(如:Mac、window是、Linux等),只要安装了虚拟机都可以直接运行字节码。并且,有了字节码,也解除了Java虚拟机和Java语言之间的耦合(java虚拟机可以支持除Java以外的语言,如Groovy、JRuby、Jython,Scala等)。

上帝视角看class文件:

  从纵观的角度看class文件,class文件里只有两种数据结构:无符号数和表。

  •   无符号数:属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节、8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者字符串(UTF-8编码)。
  • 表:表是由多个无符号数或者表作为数据项构成的复合数据类型,  class中所有的表都以“_info”结尾。其实,整个Class文件本质上就是一张表。 

class文件结构: 

      class文件中只存在无符号数和表这两种数据结构,而这些无符号数和表就组成了class中的各个结构。这些结构按照预先规定好的顺序紧密的从前向后排列,相邻的项之间没有任何间隙。

当JVM加载某个class文件时,JVM就是根据上图中的结构去解析class文件,加载class文件到内存中,并在内存中分配相应的空间。具体某一种数据结构需要占用多大空间,可以参考下图:

将一个class字节码文件通过16进制编辑器打开,显示内容如下:

上面都是一些16进制数字,每两个字符代表一个字节。JVM的视角里这些16进制字符是按照严格的规律排列的。

  • 魔数(magic number):在class文件开头的前四个字节是class文件的魔数,他是一个固定的值0XCAFEBABE。魔数是class文件的标志,它是判断一个文件是不是class格式文件的标准。如果不是class文件,则不能被JVM识别或者加载。
  • 版本号:紧跟着魔数后面的四个字节,前两个(00 00)代表的是次版本号(minor_version),后两个字节0034是主版本号。
  • 常量池(重点):紧跟在版本号之后的是一个叫做常量池的表(cp_info)。在常量池中保留了类的各种相关信息,比如类的名称、父类的名称、类中的方法名、参数名称、参数类型等,这些信息都是以各种表的形式保存在常量池中。常量池中的每一项都是一个表,其项目类型共有14种。一个字符串最大长度也就是u2所能代表的最大值65536个,但是需要2个字节来保存null,因此一个字符串的最大长度为65534。

第四节:编译插桩操纵字节码,实现不可能完成的任务

        我们大多应该都遇到过这样的需求:记录每一个页面的打开和关闭事件,并通过DataTracking的框架上传到服务器,用来日后做日志分析,这其实就是在每一个Activity页面的onCreate和onDestroy方法中,分别添加页面打开和关闭的逻辑。我们可以写一个BaseActivity,将页面的打开和关闭逻辑添加在其中,然后让所有的Activity继承它,但是这种方案对第三方依赖库中的页面则无能为力,因为我们没有第三方依赖库的源码。就是在这种环境下,一种更优雅更完成的方案应运而生:编译插桩

编译插桩是什么:

     所谓编译插桩就是在代码编译期间修改已有的代码或者生成新代码。我们项目经常用到的Dagger、ButterKnife、Kotlin,他们都用到了编译插桩技术。

Android项目中.java文件的编译过程如下图所示:

从上图可以看出,我们可以在1、2两处对代码进行改造。

  1. 在.java文件编译成.class文件时,APT、AndroidAnnotation等就是在此处触发代码生成。
  2. 在.class文件进一步优化成.dex文件时,也就是直接操作字节码文件,这种方式功能更强大,且应用场景比较多。

本课主要介绍第二种实现方式,用一张图描述如下过程,其中红色虚框包含了本课时要讲的主要内容:

一般情况下,我们经常会使用编译插桩实现如下几种功能:

  • 日志埋点;
  • 性能监控;
  • 动态权限控制;
  • 业务逻辑跳转时,校验是否已经登录;
  • 甚至是代码调试等

目前市面上主要流行两种实现编译插桩的方式:

  1. AspectJ:它是老牌AOP(Aspect-Oriented Programming)框架,其主要优势是成熟稳定,使用者也不需要对字节码文件有深入的理解。
  2. ASM:通过它可以修改现有的字节码文件,也可以动态生成字节码文件,并且它是一款完全以字节码层面来操纵字节码并分析字节码的框架。

使用ASM实现简单的编译插桩效果,在每一个Activity打开时输出相应的log日志。实现思路主要包括2步:

  1. 遍历项目中所有的.class文件:如果找出项目中编译生成的所有.class文件,是我们需要解决的第一个问题。我们知道,Android Studio使用Gradle编译项目中的.class文件,并且从Gradle 1.5.0之后,我们可以自己定义Transform,来获取所有.class文件的引用。但是Transform的使用需要依赖Gradle Plugin。因此,我们第一步需要创建一个单独的Gradle Plugin,并在Gradle Plugin中使用自定义Transform找出所有的.class文件。
  2. 遍历到目标.class文件(Activity)之后,通过ASM动态注入需要被插入的字节码:如果第一步进行顺利,我们可以找出所有的.class文件。接下来就需要过滤出目标Activity文件,在目标Activity文件的onCreate方法中,通过ASM插入相应的log日志字节码。

第五节:深入理解ClassLoader的加载机制

     一个完成的Java程序是由多个.class文件组成的,在程序运行的时候,需要将这些.class文件加载到JVM中才可以使用,而负责加载这些.class文件的就是本节要讲的类加载器(ClassLoader)。

      在Java程序启动的时候,并不会一次性的加载程序中所有的.class文件,而是在程序运行过程中,动态地加载到相应的类到内存中。通常情况下,Java中的.class文件会在以下2种情况下被ClassLoader主动加载到内存中:

  1. 调用类构造器
  2. 调用类中的静态(static)变量或者静态方法

JVM中自带3个类加载器,三者在JVM中各有分工,但是又互有依赖:

  1. 启动类加载器 (BootstrapClassLoader):它与下面两个不太一样,它并不是使用Java代码实现的,而是由C/C++语言编写的,它属于虚拟机的一部分。其加载系统属性“sun.boot.class.path”配置下类文件,其全是JRE目录下的jar包或者.class文件。
  2. 扩展类加载器 (ExtClassLoader):JDK1.9 之后,改名为PlatformClassLoader,其加载系统属性“java.ext.dirs”配置下类文件。
  3. 系统类加载器 (APPClassLoader):主要加载系统属性“java.class.path”配置下类文件,也就是环境变量CLASS_PATH配置的路径。因此AppClassLoader是面向用户的类加载器,我们自己编写的代码以及使用的第三方jar包通常都是由它来加载的。

双亲委派模式:

      所谓双亲委派模式就是,当类加载器收到加载类或资源的请求时,通常都是先委托给父类加载器加载,也就是说,只有当父类加载器找不到指定的类或资源时,自身才会执行实际的类加载工程。

比如执行以下代码:Test test = new Test();默认情况下,JVM首先使用AppClassLoader去加载Test类。

  1. AppClassLoader将加载的任务委派给它的父类加载器--ExtClassLoader。
  2. ExtClassLoader的parent为null,所以直接将加载任务委派给BootstrapClassLoader。
  3. BootstrapClassLoader在jdk/lib目录下无法找到Test类,所以AppClassLoader会调用自身的findClass方法来加载Test。最终是被AppClassLoader加载到内存中。

注意:"双亲委派"机制这是Java推荐的机制,并不是强制机制。我们可以继承java.lang.ClassLoader类,实现自己的类加载器。如果想保持双亲委派模型,就应该重写findClass(name)方法;如果想破坏双亲委派模型,可以重写loadClass(name)方法。

自定义ClassLoader:JVM中预置的3种ClassLoader只能加载特定目录下的.class文件,如果我们想加载特殊位置下的jar包或类时(比如,我要加载网络或者磁盘上的一个.class文件),默认的ClassLoader就不能满足我们的需求了,所以需要定义自己的ClassLoader来加载特定目录下的.class文件。

自定义ClassLoader步骤:

  1. 自定义一个类继承抽象类ClassLoader。
  2. 重写findClass方法。
  3. 在findClass中,调用defineClass方法将字节码转成Class对象,并返回。

实践步骤:

首先在本地电脑上创建一个测试类Secret.java,代码如下:

public class Secret{
    public void printSecret(){
        System.Out.println("我是女生");
    }
}

测试类放在磁盘所在路径如下:/Users/axing/work_dir/jvm/danny_folder;

接下来,创建DiskClassLoader继承ClassLoader,重写findClass方法,并在其中调用defineClass创建Class,代码如下:

public class DiskClassLoader extends ClassLoader{

    private String filePath;

    public DiskClassLoader(String path){
        filePath = path;
    }

    @Override
    protected Class<?> findClass(String name){
        // newPath = "file:///Users/axing/work_dir/jvm/danny_folder/Secret.class"
        String newPath = filePath + name + ".class";
        byte [] classBytes = null;
        Path path = null;
        try{
            path = Paths.get(new URI(newPath));
            classBytes = Files.readAllBytes(path);
        }catch(IOException | URISyntaxException e){
            e.printStackTrace();
        }
        
        return defineClass(name , classBytes,0,classBytes.length);
    }

}

最后写一个测试自定义DiskClassLoader的测试类,用来验证我们自定义的DiskClassLoader是否能正常work。

public void testClassLoader(){

    //创建自定义的ClassLoader对象
    DiskClassLoader diskLoader = new DiskClassLoader("file:///Users/axing/work_dir/jvm/danny_folder/");
    
    try{

        Class c = diskLoader.loadClass("Secret");
        if(c!=null){

            Object obj = c.newInstance();
            //通过反射调用Secret的printSecret方法
            Method method = c.getDeclaredMethod("printSecret",null);
            method.invoke(obj,null);
        }
    }catch(Exception e){

    }


}

注意:上述动态加载.class文件的思路,经常被用作热修复和插件化开发的框架中,包括QQ空间热修复方案、微信tinker等原理都是由此而来。客户端只要从服务端下载一个加密.class文件,然后在本地通过事先定义好的加密方式进行解密,最后再使用自定义ClassLoader动态加载解密后的.class文件,并动态调用相应的方法。

Android中的ClassLoader:本质上,Android和传统的JVM是一样的,也需要通过ClassLoader将目标类加载到内存,类加载器之间也符合双亲委派模型。但在Android中,ClassLoader的加载细节有略微的差别。在Android虚拟机里无法直接运行.class文件的,Android会将所有的.class文件转成一个.dex文件,并且Android将加载.dex文件的实现封装在BaseDexClassLoader中,而我们一般只使用它的两个子类:PathClassLoader和DexClassLoader。

  • PathClassLoader:用来加载系统apk和被安装到手机中的apk内的dex文件。其内部除了2个构造方法以外就没有其他代码了,具体的实现都是在BaseDexClassLoader里面,当一个APP被安装到手机后,apk里面的class.dex中的class均是通过PathClassLoader来加载的。
  • DexClassLoader:对比PathClassLoader只能加载已经安装应用的dex或apk文件,DexClassLoader则没有此限制,可以从SD卡加载包含class.dex的.jar和.apk文件,这也是插件化和热修复的基础,在不需要安装应用的情况下,完成需要使用的dex的加载。

总结:

  1. ClassLoader就是用来加载class文件的,不管是jar中还是dex中的class。
  2. Java中的ClassLoader通过双亲委派来加载各自指定路径下的class文件。
  3. 可以自定义ClassLoader,一般覆盖findClass()方法,不建议重写loadClass方法。
  4. Android中常用的两种ClassLoader分别为:PathClassLoader和DexClassLoader。

第六节:Class对象在执行引擎中的初始化过程

       一个class文件被加载到内存需要经过3大步:装载、链接、初始化。其中链接又可细分为:验证、准备、解析3小步。用一张图来描述class文件加载到内存中的步骤如下所示:

什么是装载:

    装载是指Java虚拟机查找.class文件并生成字节流,然后根据字节流创建java.lang.Class对象的过程,这一过程主要完成以下3件事:

  1. ClassLoader通过一个类的全限定名(包名+类名)来查找.class文件,并生成二进制字节流:其中class字节码文件的来源不一定是.class文件,也可以是jar包、zip包,甚至是来源于网络的字节流。
  2. 把.class文件的各个部分分别解析为JVM内部特定的数据结构,并存储在方法区。
  3. 在内存中创建一个java.lang.Class类型的对象:接下来程序在运行过程中所有对该类的访问都通过这个类对象,也就是这个Class类型的类对象是提供给外界访问该类的接口。

加载时机:

     一个项目经过编译之后,往往会生成大量的.class文件。当程序运行时,JVM并不会一次性的将这些.class文件全部加载到内存中,具体JVM什么时候加载某.class文件呢?对此,Java虚拟机规范中并没有严格规定,不同的虚拟机实现会有不同。不过以下两种情况一般会对class进行装载操作:

  1. 隐式装载:在程序运行过程中,当碰到通过new等方式生成对象时,系统会隐式调用ClassLoader去装载对应的class到内存中;
  2. 显式装载:在编写源代码时,主动调用Class.forName()等方法也会进行class装载操作,这种方式通常称为显式装载。

链接:

      链接的过程分为3步,验证、准备、解析。验证是链接的第一步,目的是为了确保.class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危及虚拟机本身的安全。虽然JVM会检查各种class字节码文件的篡改行为,但是依然无法百分百保证class文件的安全性。即使没有Java源文件,在某种程度上,工程师还是可以对编译之后的class字节码进行篡改。这也是为什么我们在项目总经常会使用混淆,甚至使用一些三方的加固软件,来保证我们所编写的代码的安全性。

       准备是第2步,这一阶段的主要目的是为类中的静态变量分配内存,并为其设置"0值",比如:

public static int value = 100;

在准备阶段,JVM会为value分配内存,并将其设置为0。而真正的值100是在初始化阶段设置。并且此阶段进行内存分配的仅包含类变量,而不包括实例变量(实例变量将会在对象实例化时随着对象一起分配在Java堆中)。有一种情况比较特殊,即静态常量,它会在准备阶段就为value分配内存,并设置100。

public static final int value = 100;

      解析是链接的最后一步,这一阶段的任务是把常量池中的符号引用转为直接引用,也就是具体的内存地址。在这一阶段,JVM会将常量池中的类、接口名、字段名、方法名等转换为具体的内存地址。

初始化:

     这是class加载的最后一步,这一阶段是执行类构造器<clinit>方法的过程,并真正初始化变量。比如:

public static int value = 100;

在准备阶段value被分配内存并设置为0,在初始化阶段value就会被设置为100。

初始化的时机:

     对于装载阶段,JVM并没有规范何时具体执行,但是对于初始化,JVM规范中严格规定了class初始化的时机,主要有以下几种情况会触发class的初始化:

  1. 虚拟机启动时,初始化包含main方法的主类;
  2. 遇到new指令创建对象实例时,如果目标对象类没有被初始化则进行初始化操作;
  3. 当遇到访问静态方法或者静态字段的指令时,如果目标对象类没有被初始化则进行初始化操作;
  4. 子类的初始化过程如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化;
  5. 使用反射API进行反射调用时,如果类没有进行过初始化则需要先触发其初始化;
  6. 第一次调用java.lang.invoke.MethodHandle实例时,需要初始化MethodHandle指向方法所在的类。

    在初始化阶段,只会初始化与类相关的静态赋值语句和静态语句,也就是有static关键字修饰的信息,而没有static修饰的语句块,在实例化对象的时候才会执行。

类中静态代码块、非静态代码块、构造方法之间的执行顺序:

  1. 父类的静态变量和静态代码块
  2. 子类的静态变量和静态代码块
  3. 父类的普通成员变量和普通代码块
  4. 父类的构造方法
  5. 子类的普通成员变量和普通代码块
  6. 子类的构造方法

总结:本节主要介绍了.class文件被加载到内存中所经过的详细过程,主要分为3大步:装载、链接、初始化,其中链接又包含验证、准备、解析3小步。

  1. 装载:指查找字节流,并根据此字节流创建类的过程。装载过程成功的标志就是在方法区中成功创建了类所对应的Class对象。
  2. 链接:指验证创建的类,并将其解析到JVM中使之能够被JVM执行。
  3. 初始化:则是将标记为static的字段进行赋值,并且执行static标记的代码语句。

第07讲:Java内存模型与线程

    Java内存模型,即Java Memory Model,简称JMM,它所描述的是多线程并发、CPU缓存等方面的内容。

为什么有Java内存模型

     讲JMM时,都会引用《深入理解Java虚拟机》中的一张图,如下:

     上图描述的意思是,在每一个线程中,都会有一块内部的工作内存(working memory)。这块工作内存保存了主内存共享数据的拷贝副本。在第一课时,我们了解了JVM内存结构中有一块线程独享的内存空间--虚拟机栈,所以这里会自然而然的讲线程工作内存理解为虚拟机栈。

    实际上,这种理解是不正确的!虚拟机栈和线程的工作内存并不是一个概念。在Java线程中,并不存在所谓的工作内存,它只是对CPU寄存器和高速缓存的抽象描述。

CPU普及

    线程是CPU调度的最小单位,线程中的字节码指令最终都是在CPU中执行的。CPU在执行的时候,免不了和各种数据打交道,而Java中所有的数据都是存放在主内存(RAM)当中的,如图:

    随着CPU的发展,CPU的执行速度越来越快,但内存并没有太大变化,所有在内存中读写数据的过程与CPU的执行速度比起来差距越来越大,CPU对主内存的访问需要等待较长的时间,这样就体现不出CPU超强运算能力的优势了。因此,为了压榨处理性能,达到“高并发”的效果,在CPU中添加了高速缓存来作为缓冲。

     在执行任务时,CPU会将运算所需要使用的数据复制到高速缓存中,让运算能够快速进行,当运算完成后,再将缓存中的结果刷回(flush back)主内存,这样CPU就不用等待主内存的读写操作了。

     一切看起来很美好,但问题随之而来。每个处理器都有自己的高速缓存,同时又共同操作同一块主内存,当多个处理器同时操作主内存时,可能导致数据不一致,这就是缓存一致性问题。

缓存一致性问题

     闲杂市面上的手机通常有两个或者多个CPU,其中一些CPU还是多核,每个CPU在某一时刻都能运行一个线程,这就意味着,如果你的Java程序是多线程的,那么久可能存在多个线程在同一时刻被不同的CPU执行的情况。

指令重排

      除了缓存一致性问题,还存在另一种硬件问题,也比较重要:为了使CPU内部的运算单位能够尽量被充分利用,处理器可能会对输入的字节码指令进行重排序处理,也就是处理器优化。除了CPU之外,很多编程语言的编译器也会有类似的优化,比如Java虚拟机的即时编译(JIT)也会做指令重排。

    如果我们任由CPU优化或者编译器指令重排,那我们编写的Java代码最终执行效果可能会极大地出乎我们意料。为了解决这个问题,让Java代码再不同硬件、不同操作系统中,输出的结果达到一致,Java虚拟机提出了一套机制---Java内存模型。

什么是内存模型?

      内存模型可以理解为,在共享内存系统中,多线程读写操作行为的规范,    这套规范屏蔽了底层各种硬件和操作系统的内存访问差异,解决了CPU多级缓存、CPU优化、指令重排导致的内存访问问题,从而保证Java程序(尤其是多线程程序)在各个平台下对内存的访问效果一致。

       在Java内存模型中,我们统一用工作内存(working memory)来当做CPU中寄存器或高速缓存的抽象。线程之间的共享变量存储在主内存中,每个线程都有一个私有工作内存(类比CPU寄存器或者高速缓存),本地工作中存储了该线程读/写共享变量的副本。

      在这套规范中,有一个非常重要的规则---happens-before。

happens-before先行发生原则

       happens-before用于描述两个操作的内存可见性,通过保证可见性的机制可以让应用程序免于数据竞争干扰。它的定义如下:如果一个操作A happens-before另一个操作B,那么操作A的执行结果将对操作B可见。也可以反过来这样理解,如果操作A的结果需要对另外一个操作B可见,那么操作A必须happens-before操作B。

Java中的两个操作如何就算符合happens-before规则了呢?JVM中定义了以下几种情况是自动符合happens-before规则的:

  1. 程序次序规则   
          在单线程内部,如果一段代码的字节码顺序也隐式符合happens-before原则,那么逻辑顺序靠前的字节码执行结果一定是对后续逻辑字节码可见。比如:
    int a = 10;
    b = b+a;

     

  2. 锁定规则
        无论是在单线程环境还是多线程环境,一个锁如果处于被锁定状态,那么必须先执行unlock操作才能进行lock操作。
  3. 变量规则
         volatile保证了线程可见性。通俗讲就是如果一个线程先写了一个volatile变量,然后另外一个线程去读这个变量,那么这个操作一定是happens-before读操作的。
  4. 线程启动规则
         Thread对象的start()方法先行发生于此线程的每一个动作。假定线程A 在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在线程B开始执行后确保对B线程可见。
  5. 线程中断规则
        对线程interrupt()方法的调用先行发生于被中断线程的代码检测,直到中断事件的发生。
  6. 线程终结规则
        线程中所有的操作都发生在线程的终止检测之前,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等方法检测线程是否终止执行。假定线程A在执行的过程中,通过调用ThreadB.join()等待线程B终止,那么线程B在终止之前对共享变量的修改在线程A等待返回后可见。
  7. 对象终结规则
       一个对象的初始化完成发生在它的finalize()方法开始前。

     此外,happens-before原则还具有传递性:如果操作A happens-before 操作B,而操作B happens-before 操作C,则操作A 一定happens-before 操作C。happens-before 原则非常重要,他是判断数据是否存在竞争、线程是否安全的主要依据,根据这个原则,我们能够解决在并发环境下操作之间可能存在冲突的所有问题。在此基础上,我们可以通过java提供的一系列关键字,将我们自己实现的多线程操作“happens-before”化。其实就是将本来不符合happens-before 原则的某些操作,通过某种手段使他们符合happens-before 原则。

总结:

  • Java内存模型的来源:主要是因为CPU缓存和指令重排等优化会造成多线程程序结果不可控。
  • Java内存模型是什么:本质上它就是对多线程读写操作的一套规范,在这条规范中有一条最重要的happens-before原则。
  • 最后介绍了Java内存模型的使用,简单介绍了两种方式:volatile和synchronized。除了这两种方式,其实Java还提供了很多关键字来实现happens-before原则。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值