当前位置:首页软件开发Java → JVM学习总结

JVM学习总结

时间:2020-02-08 09:35:35来源:互联网我要评论(0)

          JVM作为java的重要组成部分,在java语言发布初期就跟随着一起发布了,JVM从最初的sun公司的Classic VM,到现在常用的HotSpot VM,J9等VM,因为大部分常用的JVM都是HotSpot VM,所以在这里主要都是说的该VM。

        JVM在发展初期就规划了JVM不仅仅只是支持java语言,而且还要支持其他语言在JVM上的运行,到现在为止这个规划已经实现了,可以在JVM上运行的语言有C,PHP,Ruby,JavaScript,Erlang,Python等语言,能运行这些语言主要得益与JVM支持的是字节码格式的文件,只要编译过后的文件满足JVM的规范都可以运行,当然因为一些语言是动态型语言,所以在JVM里面新增了invokedynamic指令来优化动态型语言的支撑。

       JVM在运行时的数据区域主要分为程序计数器(Program Counter Register)、堆(Heap)、java虚拟机栈(VM Stack)、本地方法栈(Native Method Stack)、元空间Metaspace(jdk8以前使用的是方法区(Method Area)即常说的永久代),下面分别说明这些区域的特点:

        -----程序计数器:这个是一个较小的内存空间,可以看作是当前线程所执行的字节码行号的指示器。各个线程之间的程序计数器互不影响,独立存储,这样可以满足线程切换后能恢复到正确的执行位置。如果线程执行的是一个java方法,则这个计数器记录的是正在执行虚拟机字节码指令的地址;如果是Native方法,这个计数器值则为空。这也是唯一一个在java虚拟机规范中没有规定任何OutofMemoryError的区域。

        -----Java虚拟机栈:它描述的是java方法执行的内存模型,每一个方法在执行的同时都会创建一个栈帧,该栈帧用于存储局部变量表、操作数栈、动态链接和方法出口等信息。每一个方法的执行过程就对应一个栈帧从虚拟机栈中入栈到出栈的过程。

      局部变量表是用于存储编译期可预知的各种基本数据类型(int,char,long,boolean,byte,float,double,short)、对象引用(reference类型,它可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向一条字节码指令地址)。

        Java虚拟机规范指定了这个区域两种类型的异常情况:一是如果线程请求的栈深度大于了虚拟机允许的栈深度,将抛出StackOverflowError异常;二是如果虚拟机栈可以扩展,但是没有足够的内存来进行扩展的时候,就会抛出OutOfMemoryError异常。

        -----本地方法栈:它是用于执行虚拟机Native方法的内存模型。java虚拟机规范没有规定对本地方法栈中方法使用的语言、使用方式和数据进行强制规定,可以自由实现。与虚拟机栈一样,也会抛出StackOverflowError和OutOfMemoryError异常。

        -----Java堆:它是java虚拟机所管理的内存的最大一块,是被所有的线程共享的一块内存区域,在虚拟机启动的时候创建。它用于存放字符串常量、静态变量和对象实例,几乎所有的对象实例都在这里分配内存。java堆是垃圾回收的主要区域,因此也被称为“GC堆”。按照内存回收来划分,可以分为新生代和老年代,在细分可以分为Eden空间、To Survivor空间、From Survivor空间等空间;从内存分配来划分,线程共享的java堆中能划分出多个线程私有的分配缓冲区。根据java虚拟机规范规定,java堆可以处于不连续的内存空间中,只要逻辑上连续即可,如果堆中没有足够的内存完成对象实例的分配,并且堆无法扩展时将会抛出OutOfMemoryError异常。

        -----元空间:它用于存放元数据,即类信息、即时编译器编译后的代码等数据。它的大小默认情况下受限于本地内存的大小,如果元空间的大小超过了本地内存的大小,也会抛出OutOfMemoryError异常。

        以上就主要是JVM在运行时的数据区域的划分以及存储的特点,下面我们看看对象的内存分配。在java虚拟机中对象在内存中的存储可以分为3个区域:对象头(Header)、实例数据和对齐填充(Padding)。

       -----对象头:它包括两部分,第一部分用于存储对象自身运行时的数据,包括哈希码、GC分代年龄、锁状态标识等;第二部分是存储类型指针,即对象指向它的类元数据指针,虚拟机通过这个指针来确定这个对象是哪个类的实例;需要注意的是如果对象是一个数组,对象头中必须还要存放数组的大小,因为从数组元数据中是无法确认数组大小的。

        -----实例数据:它是存储在程序代码中定义的各种类型的字段内容。

        -----对象填充:它可有可无,没有特别的含义,仅仅是起占位符的作用。

        接下来说说java虚拟机的垃圾回收机制,在说垃圾回收机制之前我们该如何判断哪些对象可以进行回收呢?通过什么方式来判断对象已"死去"?在这里我们有2种主要的方式可以进行判断对象“已死”,分别是引用计数法和对象可达性分析。

       -----引用计数法:在很多的教科书里定义如下,给对象中添加一个引用计算器,每当一个地方引用它,就给计数器加1,当引用失效时,计数器减1,任何时刻计数器为0的对象就是不可能在被使用。

       ------对象可达性分析:它的基本思想就是通过一系列的“GC Roots”的对象作为起始点,通过这些节点向下搜索,搜索所走过的路径成为引用链,当一个对象到GC Roots没有任何引用链相连时,说明这个对象是不可用的。在java语言中GC Roots的对象包括:虚拟机栈(栈帧中的本地变量表)中引用的对象、java堆中类的静态属性引用的对象、java堆中常量应用的对象以及本地方法栈中JNI引用的对象。

       在java中引用可以分为强引用、软引用、弱引用、虚引用4种。

       -----强引用:这种引用在java代码中普通存在,如“Object obj = new Object()”,只要这种引用存在,那么垃圾收集器就不会回收掉引用的对象。

        -----软引用:描述一些还有用但非必要的对象。对于软引用关联的对象,在将要发生内存溢出异常之前,将会把这些对象列进回收范围进行第二次回收。如果回收后内存还是不足,将会发生内存溢出异常。

        -----弱引用:描述的是非必要的对象,被弱引用的对象只能生存到下一次垃圾收集之前。

        -----虚引用:也称为幽灵引用或幻影引用,一个对象设置为虚引用完全不会影响对象的生存时间,也不能通过虚引用来获取对象实例,其唯一目的是这个对象被收集器回收时给这个对象一个系统通知。

       那么如何回收垃圾呢?通过什么算法来实现呢?在这里主要介绍几种垃圾收集算法,标记-清除算法、复制算法、标记-整理算法、分代收集算法。

        -----标记-清除算法:它是最基础的算法,这个算法分为标记和清除2个阶段,它首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。该算法有2个不足:一是效率问题,标记和清除的过程的效率都不高;二是空间问题,清除过后会产生大量的不连续内存碎片,在遇到大对象分配内存时由于空间碎片太多,并且当内存不足时将会导致提前另一次垃圾回收动作。

        -----复制算法:主要是为了解决效率问题,它将内存分成大小相等的两块,每次只能使用其中一块,当一块内存使用完后,它将这块中还存活的对象复制到另外一块内存中,将当前这一块的内存空间清理掉。该算法的不足是将内存的使用率缩小为原来的一半,这个代价是相当高昂的,优点就是实现简单,运行高效。

        -----标记-整理算法:这个算法也分为2个阶段,它首先标记出所有需要回收的对象,然后在将存活的对象向一端移动,然后直接清除掉端边界以外的内存。

        -----分代收集算法:根据对象的存活周期分成几块内存区域,一般是把java堆分为新生代和老年代,根据各个年代的特点采用以上的最合适的收集算法。

        有了上面的垃圾收集算法,那么肯定就有对应的垃圾收集器,这里主要介绍几种垃圾收集器,seriall收集器、parnew收集器、parallel scavenge收集器、serial old收集器、parallel old收集器、cms收集器、g1收集器。

         -----seriall收集器:它是最基本,发展最久的垃圾收集器,它是一个单线程的垃圾收集器,新生代采用了复制算法,在它进行垃圾收集的时候,必须暂停其他的所有工作线程,直到它的收集工作完成。其优点就是简单高效,其缺点就是暂停工作线程影响系统运行。该收集器主要运行在虚拟机的Client模式下。

        -----parnew收集器:它是seriall收集器的多线程版本,许多设置基本与seriall收集器一样,主要运行在虚拟机的Service模式下。

       -----parallel scavenge收集器:它是一个新生代收集器,采用了复制算法,且是并行的多线程收集器,它的目标是到达一个可控制的吞吐量(CPU用于运行用户代码的时间与CPU总消耗时间的比值)。该收集器适合用于后台运行而不需要太多的交互任务的情况下使用。可以通过2个参数来精确控制吞吐量,分别是控制最大垃圾收集器停顿时间的-XX:MaxGCPauseMillis参数及直接设置吞吐量大小的-XX:GCTimeRatio参数,特别说明-XX:GCTimeRatio参数是指用户代码运行的时间,而GC的时间只占1份,如设置为19,那么垃圾收集时间就为1/(1+19)=5%,也就是说吞吐率为95%,该值的范围是1---100,默认值为99。

       -----serial old收集器:它是serial收集器的老年代版本,也是一个单线程收集器,采用了标记-整理算法,主要用于Client模式下的虚拟机使用。

      ------parallel old收集器:它是parallel scavenge收集器的老年代版本,使用多线程和标记-整理算法。

      -----cms收集器:它的全名是Concurrent Mark Sweep,它是一种获取最短停顿时间为目标的收集器。它采用的是标记-清除算法,它的收集过程为初始标记-->并发标记-->重新标记-->并发清除。它的主要优点是并发收集,低停顿;缺点是:一是对CPU资源敏感,因为要启动一部分线程来进行垃圾收集,因此会造成应用程序变慢,总吞吐量会降低,其默认启动的回收线程数是(cpu数量+3)/4,也就相当于cpu数量在4个以上时,并发回收时的垃圾手机线程不少于25%的CPU资源,并随着CPU增加而下降;二是无法处理浮动垃圾,可能出现"Concurrent Mode Failure"失败而导致一次FULL GC。产生浮动垃圾的原因是在垃圾收集阶段用户的程序还在运行,因此就会有新的垃圾产生,CMS无法在当次收集中处理它们,只好留在下一次GC的时候在清理,而这部分垃圾就是浮动垃圾;三是由于是采用的是标记-清除算法,因此会产生许多的内存碎片,这样在给大对象分配内存时,当内存空间不足,就会产生一次FULL GC操作。为解决FULL GC的问题,虽然提供了-XX:UseCMSCompactAtFullCollection开关参数(默认开启)用于整理内存碎片的合并整理,但是内存整理是无法并发的,所以停顿时间不得不变长。同时也提供了另外一个参数-XX:CMSFullGCBeforeCompaction用于设置多少次不压缩的FULL GC后,跟着来一次压缩(默认为0,表示每次进行FULL GC后就进行碎片整理)。

        -----g1收集器:它是面向服务端的垃圾收集器。它的特点是并行与并发、分代收集、空间整合、可预测的停顿。g1收集器将整个java堆划分为多个大小相等的独立区域(Region),虽然保留了新生代和老年代的概念,但是不在物理隔离,它们都是一部分Region(不需要连续)的集合。至于可以建立可预测的停顿时间模型是因为可以有计划的避免在整个java堆中进行全区域的垃圾收集。g1跟踪每个Region里面垃圾堆积的价值大小而在后台维护一个优先列表,每次根据允许的收集时间优先回收价值最大的Region,这种收集回收方式保证了g1收集器在有限的时间里获取尽可能高的收集效率。g1收集器的运作过程大致分为初始标记-->并发标记-->最终标记-->筛选回收。

      说完了对象回收的垃圾收集器,那么怎么给对象分配内存呢?对象分配内存策略有如下几点,对象优先在eden分配、大对象直接进行老年代、长期存活对象进入老年代、动态对象年龄判断、空间分配担保。

        -----对象优先在eden分配:在大多数情况下,对象在新生代的eden区分配。当eden区没有足够的内存的时候,虚拟机将会进行一次minor gc。

        -----大对象直接进行老年代:这里的大对象是指需要大量连续的内存空间的java对象,如很长的字符串和数组。可以通过-XX:PretenureSizeThreshold参数来令大于这个设置值的对象直接进入老年代,这样可以避免在eden区和两个survivor区之间发生大量的内存复制操作。

        -----长期存活对象进入老年代:虚拟机会给每个对象定义一个对象年龄计数器。如果对象在eden区"出生"并且经过第一次minor gc后仍然存活,并且能被survivor容纳的话,将被移动到survivor空间中,并且对象年龄加1,对象每再survivor空间中“熬过”一次minor gc,年龄就增加1,当年龄增加到一定程度(默认位15),就会晋升到老年代中。可以通过-XX:MaxTenuringThreshold设置晋升老年代年龄阈值。

        -----动态对象年龄判断:虚拟机并不要求对象的年龄一定要到达MaxTenuringThreshold的设置年龄才能晋升老年代,当在survivor空间中相同年龄的对象大小的总和大于survivor空间大小的一半时,年龄大于或等于该年龄的对象就可以直接晋升老年代。

        -----空间分配担保:在发生minor gc之前,虚拟机会先检查老年代最大的可用连续空间是否大于新生代所有对象空间,如果这个条件成立,那么minor gc可以确保是安全的;如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败;如果允许,则会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次minor gc,尽管这次minor gc会有风险,如果小于,或者设置HandlePromotionFailure不允许担保失败,则进行一次full gc。这里的风险是指当进行一次minor gc过后,仍然有很多对象存活,就需要老年代进行分配担保,把survivor区无法容纳的对象直接进入老年代,这里的老年代是否有足够的空间来容纳对象就需要根据以往晋升到老年代的对象的平均值来对比,决定是否也需要进行full gc来让老年代腾出更多空间。

       讨论完对象的内存分配,那么我们可以通过哪些工具来监控虚拟机的状态呢?在这里官方提供了在java安装目录下的jdk/bin目录下的许多有用工具,如jps、jstat、jstack等,下面主要说明下这些工具的作用。

        -----jps:JVM Process Status Tool,显示指定系统内所有hotspot虚拟机进程。

        -----jstat:JVM Statistics Monitoring Tool,用于收集hotspot虚拟机各方面的运行数据。

        -----jinfo:Configuration info for Java,显示虚拟机配置信息。

        -----jmap:Memory Map for Java,生成虚拟机的内存转储快照(heapdump文件)。

        -----jhat:JVM Heap Dump Browser,用于分析heapdump文件,它会建立一个HTTP/HTML服务器,让用户可以在浏览器上查看分析结果。

       -----jstack:Stack Trace for Java,显示虚拟机的线程快照。

       在这个目录下还可以看到jconsole和jvisualvm这2个工具,它们主要是可视化的工具,里面包括了上面介绍的所有单个命令的用法,可以在界面上展示出来查看。

        了解完了虚拟机内存的监控工具,我们来看看类的加载机制吧。类从被加载到虚拟机内存开始,到从内存中卸载为止,其生命周期包括加载、验证、准备、解析、初始化、使用、卸载等7个阶段。而加载、验证、准备、初始化、卸载这5个阶段先后顺序是确定的。

         类如果要被初始化必须满足5种条件中的一种即可:

         -----遇到new、getstatic、putstatic或invokestatic这4条指令的时候,如果类没有被初始化,则必须进行初始化才能使用。其主要使用场景是:使用new关键字创建类、调用类的静态变量字段以及调用类的静态方法。

        -----使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有被初始化,则必须进行初始化才能使用。

       ------初始化一个类的时候,如果其父类没有初始化,则必须先初始化其父类。

       -----当虚拟机启动时,需要指定一个执行的主类(包含main方法的那个类),虚拟机会先初始化这个主类。

       -----当使用JDK1.7的动态语言支持时,如过一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄对应的类没有初始化,则必须先进行初始化,说简单点就是使用java.lang.invoke.MethodHandle调用类的一个方法的时候,如果这个方法对应的类没有被初始化,则必须先初始化。

        知道了类加载的初始化条件,那么我们接下来了解下类加载的几个过程都做了些什么:

        -----加载:是“类加载”过程的一个阶段,不用弄混淆了,它主要完成3件事,一是通过一个类的全限定名来获取定义此类的二进制字节流;二是将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构;三是在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口。

       这里有一个特列就是数组类的生成,它是由虚拟机直接创建的,并不由类加载进行加载,但是其数组类的元素类型却要通过类加载创建,一个数组类的创建遵循如下3个原则,一是如果数组的组件类型(指的是数组去掉一个维度的类型)是引用类型,那就递归采用加载过程去加载这个组件类型,该数组类将在加载该组件类型的类加载器的类名称空间上被标识;二是如果数组的组件类型不是应用类型(如int[]数组),java虚拟机将会把该数组标记为与引导类加载器关联;三是数组的可见性与它的组件类型的可见性一致,如果组件类型不是引用类型,那数组的可见性默认为public。

        -----验证:该阶段的目的是确保Class文件中的字节流包含的信息符合当前虚拟机的要求,不会出现危害虚拟机的安全。主要可以分为4个阶段的检验动作:首先是文件格式的验证,主要校验字节流是否符合Class文件格式的规范,并且能否被当前的虚拟机版本所处理;然后是元数据验证,该验证主要是对字节码描述的信息进行语义分析,以保证其描述的信息符合java语言规范;再次是字节码验证,主要通过验证数据流和控制流分析,确定程序语义是合法的、符合逻辑的;最后符号引用验证,它可以看做是对类自身以外(常量池中各种符合引用)的信息进行匹配性校验,如符号引用中通过字符串能否根据全限定名找到对应的类、符号引用中的类、字段、方法的访问性(public、protect、private)是否可被当前类访问,其目的是保证解析动作能正常执行。

        -----准备:该阶段是正式为类变量(被static修饰的变量)分配内存并且设置类变量初始值(通常情况下是数据类型的零值)的阶段,这些类变量所使用的内存都将在java堆中分配内存。

        -----解析:该阶段是虚拟机将常量池里的符号引用替换位直接引用的过程。符号引用是一组用符号来描述所引用的目标,符合可以是任意形式的字面量,只要使用时能够无歧义的定位到目标即可,它与虚拟机实现内存布局无关,引用的目标不一定已加载到内存中;直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄,它与虚拟机的实现内存布局有关,同一个符号引用在不同虚拟机上翻译出来的直接引用一般不会相同。对符号引用的解析必须发生在执行anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putstatic和putfield这16个用于操作符符号引用的字节码指令之前。

      解析的动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行,分别对应常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info和CONSTATNT_InvokeDynamic_info7种常量类型。

        -----初始化:该阶段是根据程序员通过程序去制定的主观计划去初始化类变量和其他资源,从另外一个角度来表达就是初始化阶段是执行类构造器<clinit>()方法的过程。下面主要介绍一下<clinit>()方法的原理:

        1.<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器的收集顺序是由语句在源文件出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,虽然可以在前面定义的静态语句块中给变量赋值,但是不能访问,即作为输出值使用;从产生<clinit>()方法原理也说明,<clinit>()方法并不是必须的,因为类中可以没有类变量和静态语句块。

        2.虚拟机将会首先执行父类的<clinit>()方法,然后在执行子类的<clinit>()方法,因此虚拟机中第一个被执行的<clinit>()方法是java.lang.Object的,这个也同时说明了父类中定义的静态语句块要优先于子类的变量赋值。

       3.接口中不能有静态语句块,但是可以有变量初始化赋值操作,因此也会生成<clinit>()方法。接口与类不同,不需要先执行父接口中的<clinit>()方法,只有当需要使用父接口中的变量时,才会执行父接口中的<clinit>()方法。当然,接口的实现类在初始化时也不会执行接口的<clinit>()方法。

        4.虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确的加锁、同步。如果多个线程去初始化一个类,那么也只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完成。

        以上就是类加载的5个过程,那么是怎样实现类加载的呢?在java里是通过类加载器进行加载的,并且每一个类都是由这个类和加载这个类的类加载器一同确立它在虚拟机中的唯一性,每一个类加载器都有它自己的独立类名称空间,也就是说一个类在同一个虚拟机之中,如果由2个不同的类加载器加载,那么它们也是不相等的。在大多数的java程序中会使用到3种系统提供的类加载器,一是启动类的加载器,这个类负责把<JAVA_HOME>\lib目录中,或者被-Xbootclasspath参数所指定的路径中,并且是虚拟机识别的加载到内存中;二是扩展类加载器,它负责加载<JAVA_HOME>\lib\ext中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用它;三是应用程序类加载器,它负责将用户类路径上(ClassPath)的上所指定的类库,开发者也可以直接使用它。在java中大多数情况下是使用双亲委派模型的,即除了启动类加载器以外,其他的类加载器都有自己的父类加载器,但是这里的类加载器之间的父子关系一般不会以继承关系实现,而是以使用组合方式来复用父加载器代码。双亲委派模型工作原理是如果一个类加载器收到类加载请求,它首先会把这个请求委派给父加载器去完成,因此所有的加载请求最终都会传送到顶层的启动类加载器中,只有当父加载器无法完成加载请求的时候,子加载器才会尝试自己去加载。类加载器的优先关系是启动类加载器-->扩展类加载器-->应用程序类加载器-->自定义类加载器。

        类加载的过程也是解释和编译的过程,解释和编译应对的就是虚拟机的解释器和编译器,那么解释器和编译器是如何相互工作的呢?在大多数主流虚拟机里面都采用了解释器与编译器共存的方法,当程序需要运行的时候,解释器可以首先发挥作用,省去编译时间,立即执行;随着程序的运行,编译器把越来越多的程序编译为本地代码之后,就可以获取更高的执行效率;同时,解释器还可以作为编译器激进优化的一个“逃生门”,让编译器根据概率选择一些大多数时候能提升运行速度的优化手段,当激进优化的手段不成立,如加载了新类后继承结构发生了变化,出现“罕见缺陷”时可以通过逆优化退回到解释状态继续执行。

        在运行过程中,有两类会被编译器即时编译的代码,分别为被多次调用的方法和被多次执行的循环体,他们被称为“热点代码”,那么如何判断是“热点代码”呢?也有2种方式来进行判断:

        -----基于采样的热点探测:采用该方法的虚拟机会定期的检查各个线程的栈顶,如果发现某个或某些方法经常出现在栈顶,那这个方法就是“热点方法”。该方法好处是简单、高效,且很容易获取方法调用关系,缺点是很难精确的确认一个方法的热度,容易受到线程阻塞或别的外界因素影响扰乱热点探测。

        -----基于计数器的热点探测:采用该方法的虚拟机会为每个方法(甚至代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定“阈值”就认为是“热点方法”。该方法的好处是统计结果相对来说更加精确和严谨,缺点是要为每个方法建立并维护计数器,且不能获取到方法的调用关系。hotspot虚拟机就是采用了该方法,我们可以通过-XX:CompileThreshold来设定阈值,默认的情况是Client模式下1500次,Server模式下是10000次,这两个模式是hotspot虚拟机内置的即时编译器Client Compiler和Server Compiler的对应模式,也分别成为C1和C2编译器。

        在程序启动运行后,线程的并发运行是一定会发生的,那么内存是如何保障并发的呢?这就要根据java的内存模型来说明,java的内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存以及从内存中取出变量的底层细节,该变量只包括实例字段、静态字段和构成数组对象的元素。java内存模型分为主内存和工作内存,主内存用于存储所有的变量,工作线程属于线程所有,它用于保存被该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作,都必须在自己的工作内存中进行,不能操作其他线程的工作内存,更不能直接操作主内存的变量。java内存模型也定义了8种操作来完成主内存和工作线程的交互,并且这8种操作都是原子的、不可再分的,8种操作如下:

        -----lock(锁定):作用于主内存的变量,用于把变量标识为一条线程独占的状态;

        -----unlock(解锁):作用与主内存的变量,用于把变量标识从锁定状态释放出来,释放后的变量才能被其他的线程锁定;

        -----read(读取):作用于主内存变量,用于把变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用;

       -----load(载入):作用于工作内存变量,用于把read操作从主内存中得到的变量值放入工作内存的变量副本中;

       -----use(使用):作用于工作内存变量,用于把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行这个操作;

       -----assign(赋值):作用于工作内存变量,用于把一个从执行引擎接收到的值赋给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就会执行这个操作;

        -----store(存储):作用于工作内存变量,用于把一个变量值从工作内存中传送到主内存,以便随后的write操作;

        -----write(写入):作用于主内存变量,用于把store操作从工作内存中得到的变量值放入主内存的变量中。

       在上面的8种内存访问操作中,又定义了如下的规则:

       -----一个变量从主内存到工作内存,就必须先后执行read和load操作,变量从工作内存到主内存,就必须先后执行store和write操作,注意这里是先后执行,并不是连续执行;

       -----不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存中读取了,但是工作内存不接收,或者从工作内存发起了回写,但是主内存不接收的情况;

        -----不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须同步回主内存中;

        -----不允许一个线程无原因的(没发生过任何的assign操作)把数据从线程的工作内存同步回主内存中;

        -----一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化的变量,即一个变量实施use,store操作之前,必须先执行过assign和load操作;

        -----一个变量在同一时刻只允许同一个线程对其lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,才能被其他线程使用;

       -----一个变量执行lock操作,将会清空工作内存中的此变量的值,在执行引擎使用该变量前,必须执行assign或load操作初始化变量的值;

       -----一个变量事先没有被lock操作锁定,那么也不允许使用unlock操作进行解锁,也不允许去unlock一个被其他线程lock的变量;

      -----一个变量在执行unlock操作之前,必须先同步回主内存,即执行store、write操作。

      当然,这里除了以上8种内存访问操作,还有一个特殊的volatile修饰的变量,虽然定义上是变量的变化对所有线程具有可见性,但是在不符合如下两条规则的情况下也必须保证通过加锁(使用synchronized或java.util.concurrent中的原子类)的方式来保证原子性,一是运算结果并不依赖当前值,或者能确保只有单一线程修改变量值;二是变量不需要与其他的状态变量参与不变约束。

        如果仅仅是依靠上面的描述来实现java内存模型,那么所有的操作将变得复杂,所以java中出现了一个“先行发生”的原则,它主要用于判断数据是否存在竞争、线程是否安全的主要依据。先行发生是指java内存模型中定义的两项操作之间的偏序关系,如果说操作A先发生与操作B,即发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括了内存中共享变量的值、发送了消息、调用了方法等。存在先行发生的规则定义如下:

        -----程序次序规则:在一个线程内,按照程序代码的顺序,书写在前面的操作现行发生于书写在后面的操作。准确来说是程序控制流的顺序而不是程序代码的顺序,因为要考虑分支、循环等结构;

        -----管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里强调的是同一个锁,“后面”是指时间的先后顺序;

        -----volatile变量规则:对一个volatile变量的写操作先发生于后面对这个变量的读操作。这个“后面”也是指时间的先后顺序。

        -----线程启动规则:Thread对象的start()方法先行发生于此对象的每一个操作;

       ------线程终止规则:线程中所有的操作都先行发生于对线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()返回值等手段检测到线程已终止执行;

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

        -----对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始;

        -----传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。

        以上的先行发生规则无须任何的同步手段就可以实现,但是“时间发生的先后顺序”与“先行发生原则”之间是基本没有太大关系的,比如不在同一个线程发生的读取操作是无法保证最终获取值的,所以在衡量并发安全问题的时候不能受到时间顺序的干扰,必须以先行发生原则为准。

        在java中数据的共享可分为5类,即不可变、绝对线程安全、相对线程安全、线程兼容和线程对立,分辨介绍如下:

        -----不可变:它所指的对象一定是线程安全的,无论是对象的方法调用还是方法实现,都不需要实现任何的线程安全保障措施,最简单的实现方式就是通过final关键字修饰;

       -----绝对线程安全:它是指不管运行时环境如何,调用者都不需要额外的同步措施。这个定义虽然很好,但是却很难实现,即使实现也要付出不必要的代价;

        ------相对线程安全:它就是我们通常所说的线程安全,它需要保证对这个对象的单独操作是线程安全的,我们在调用的时候不用做其他的保障措施,但对于一些特定顺序的连续调用,则需要在调用端使用额外的措施来保证调用的正确性;

        -----线程兼容:它指对象本身并不是线程安全的,需要调用端使用正确的同步手段来保障在多线程环境下的安全使用;

        -----线程对立:它是指无论是否采取同步措施,都无法在多线程环境里并发使用代码。

        那么我们又如何来实现线程安全呢?可以通过互斥同步、非阻塞同步和无同步方案来实现。

        -----互斥同步:它是指多个线程在并发访问共享数据时,保证共享数据在同一时刻只有一个线程访问,也被成为阻塞同步,属于一种悲观的并发策略,其互斥方式有临界区、互斥量和信号量,最基本的互斥手段就是使用synchronized关键字;

        -----非阻塞同步:它是指先进行操作,如果共享数据没有其他线程争用,那么就成功了,如果有其他线程争用,产生了冲突,则采取其他的补偿措施,直到成功为止,这种策略并不需要挂起线程,因此也被称为乐观的并发策略。

        -----无同步方案:它是指方法本来就不涉及共享数据,因此也不需要同步措施去保障数据的正确性。

 


相关文章

网友评论

热门评论

最新评论

发表评论 查看所有评论()

昵称:
表情: 高兴 可 汗 我不要 害羞 好 下下下 送花 屎 亲亲
字数: 0/500 (您的评论需要经过审核才能显示)

关于万荚 | 联系方式 | 发展历程 | 版权声明 | 帮助(?) | 网站地图 | 友情链接

Copyright 2005-2020 16WJ.COM 〖万荚网〗 版权所有 桂ICP备18000060号 |

声明: 本站所有文章来自互联网 如有异议 请与本站联系