运行时数据区域
方法区,虚拟机栈,本地方法栈,堆 和程序计数器。
程序计数器:
是一块较小的内存空间,它的作用可以看作是当前线程所执行的字节码的行号指示器。
每一个线程都有自己私有的程序计数器。
如果线程正在执行的是一个JAVA方法,该计数器记录的是正在执行的虚拟机字节码指令的地址,如果正在执行的是native方法,则计数器值为空(undefined)。此内存区域是唯一一个在JAVA虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
JAVA虚拟机栈:
也是线程私有的,生命周期和线程相同。每个方法被执行的时候都会同时创建一个栈帧(stack frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每个方法被调用直至执行完成的过程,就对应一个栈帧在虚拟机栈中从入栈道出栈的过程。
这个JAVA虚拟机栈就是我们常说的“栈”。局部变量表存放了元数据类型、引用类型
局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
在JVM规范中,对这个区域规定了两种异常情况:
如果线程请求的栈深度大于JVM允许的深度,抛出StackOverflowError
如果虚拟机栈可以动态扩展(目前大部分JVM都可动态扩展),当扩展时无法申请到足够的内存时,会抛出OutOfMemoryError异常
本地方法栈:
与虚拟机栈作用类似,只不过前者是执行JAVA方法服务,而本地方法栈是为Native方法服务。
HotSpot将本地方法栈和虚拟机栈合二为一。
和虚拟机栈一样会抛出来两种异常。
JAVA堆:
JAVA堆是被所有线程共享的一块内存区域,在JVM启动时创建。其作用就是存放对象实例。
JVM规范规定:所有的对象实例及数组都要在堆上分配。
JAVA堆也是垃圾回收管理的主要区域。
由于现在收集器基本采用分代收集算法,所以JAVA堆还可以细分为:新生代和老年代,再细分还有Eden空间、From Survivor空间、To Survivor空间。
根据JVM规范,JAVA堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,可以是固定大小的,也可以是可扩展的(用-Xmx和-Xms控制),如果堆中没有内存,也无法扩展,抛出OutOfMemoryError。
方法区:
也是所有线程共享的内存区域。它用于存放虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
在JVM规范中被描述为堆的一部分,但却又有一个non-heap的别名。
对于HOTSPOT虚拟机,方法区又被称为永久代(Permanent Generation)
这个区域一样不需要连续的内存,可以选择固定大小或者扩展,还可以选择不实现垃圾回收。确实这个区域的数据一般不参与回收,但这些数据并不一定就是永久存在了,常量池和对类型的卸载也可以成为回收的目标。
抛出OutOfMemoryError
运行时常量池(方法区的一部分):
量池中。 是方法区的一部分。
在编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常
这个区域并不是只有编译时产生的常量,也有运行时产生的常量,比如String.intern()方法。
既然运行时常量池是方法区的一部分,自然会受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。
直接内存:
Direct Memory
在JDK1.4中引入了NIO,是一种基于通道与缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在JAVA堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。
本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括RAM及SWAP区或者分页文件)的大小及处理器寻址空间的限制。服务器管理员配置虚拟机参数时,一般会根据实际内存设置-Xmx等参数信息,但经常会忽略掉直接内存,使得各个内存区域的总和大于物理内存限制(包括物理上的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。
Object obj = new Object();
Object obj反映到JAVA栈的本地变量表中
new Object()反映到JAVA堆中,长度不确定,在JAVA堆中还必须包含能查找到此对象类型数据(如对象类型、父类、实现的接口、方法等)的地址信息,这些类型数据则存放在方法区中。
引用访问对象的方法主流的有两种:
使用句柄访问方式,JAVA堆中将会划分出一块内存来作为句柄池,引用对象存储的是对象的句柄地址,而句柄中包含着对象实例数据和类型数据各自的具体地址信息。
直接指针访问方式。引用对象中直接存储的就是对象地址。在堆中的对象又有一个指针指向方法区的对象类型数据。
句柄访问方式最大的好处是在对象被移动时(垃圾回收经常会移动对象),只用改变句柄中的实例数据指针,而引用对象不用修改。
直接指针方式最大的好处是速度更快。HOTSPOT使用这种方式。
设置堆不可扩展,将-Xms和-Xmx参数设置一样即可。
栈溢出
-Xoss参数设置本地方法栈大小(在HOTSPOT虚拟机中,由于本地方法栈和虚拟机栈是一起的,所以这个参数是无效的),-Xss参数设定栈大小。
栈溢出会抛出两种异常stackoverflow和outofmemory,前者表示线程请求的栈深度大于虚拟机所允许的最大深度,后者表示无法申请到足够的内存空间。
实验表明,单线程只会有stackoverflow,不会有outofmemory.但多线程会产生Outofmemory。
这是因为,OS分配给每个进程的内存是有限制的,比如32位windows限制为2GB,栈内存的总量是由2GB减去堆内存(Xmx),再减去方法区容量(MaxPermSize),因为程序计数器消耗内存很小,可以忽略不计。而栈内存又被多个线程所瓜分,这样在线程很多的情况下,内存就可能会耗尽。
运行时常量池溢出
通过String.intern()方法可以向运行时常量池增加内容,该方法会检测常量池中是否有某个字符串,如果有就返回池中的字符串,否则将该字符串加入常量池,再返回该常量池字符串的引用。
由于常量池分配在方法区中,可以通过-XX:PermSize和-XX:MaxPermSize来限制方法区大小。方法区溢出的标志就是OutOfMemoryError: PermGen Space.
方法区溢出
主要是由于动态生成了太多的类造成的,这种溢出的实际场景比如大量JSP或动态生成JSP文件的应用(JSP第一次运行时会需要编译成JAVA类)CGLIB等动态代理生成框架产生的动态类等
本机直接内存溢出
DirectMemory容量可以由-XX:MaxDirectMemorySize指定,如果不指定,则默认与JAVA堆最大值(-Xmx)一样。实际上DirectByteBuffer并没有直接申请分配内存,而是先计算,如果计算得知无足够内存可分配,就抛出异常。
1.跟踪收集器
2.复制算法
2.引用计数收集器
跟踪收集器
跟踪收集器采用的为集中式的管理方式,全局记录对象之间的引用状态,执行时从一些列GC Roots的对象做为起点,从这些节点向下开始进行搜索所有的引用链,当一个对象到GC Roots 没有任何引用链时,则证明此对象是不可用的。
下图中,对象Object6、Object7、Object8虽然互相引用,但他们的GC Roots是不可到达的,所以它们将会被判定为是可回收的对象。
可作为GC Roots 的对象包括:
虚拟机栈(栈帧中的本地变量表)中的引用对象。
方法区中的类静态属性引用的对象
方法区中的常量引用的对象
本地方法栈中JNI的引用对象。
主要有复制、标记清除、标记压缩三种实现算法。
1. 标记 - 清除算法
标记清除算法是最基础的收集算法,其他收集算法都是基于这种思想。标记清除算法分为“标记”和“清除”两个阶段:首先标记出需要回收的对象,标记完成之后统一清除对象。
它的主要缺点:
①.标记和清除过程效率不高
②.标记清除之后会产生大量不连续的内存碎片。
2. 复制算法
它将可用内存容量划分为大小相等的两块,每次只使用其中的一块。当这一块用完之后,就将还存活的对象复制到另外一块上面,然后在把已使用过的内存空间一次清理掉。这样使得每次都是对其中的一块进行内存回收,不会产生碎片等情况,只要移动堆订的指针,按顺序分配内存即可,实现简单,运行高效。
主要缺点:
内存缩小为原来的一半。
3. 标记 - 整理算法
标记操作和“标记-清除”算法一致,后续操作不只是直接清理对象,而是在清理无用对象完成后让所有存活的对象都向一端移动,并更新引用其对象的指针。
主要缺点:
在标记-清除的基础上还需进行对象的移动,成本相对较高,好处则是不会产生内存碎片。
引用计数收集器
引用计数收集器采用的是分散式管理方式,通过计数器记录对象是否被引用。当计数器为0时说明此对象不在被使用,可以被回收。
主要缺点:
循环引用的场景下无法实现回收,例如下面的图中,ObjectC和ObjectB相互引用,那么ObjectA即便释放了对ObjectC、ObjectB的引用,也无法回收。sunJDK在实现GC时未采用这种方式。
8.G1收集器
HotSpot JVM收集器
上面有7中收集器,分为两块,上面为新生代收集器,下面是老年代收集器。如果两个收集器之间存在连线,就说明它们可以搭配使用。
Serial(串行GC)收集器
Serial收集器是一个新生代收集器,单线程执行,使用复制算法。它在进行垃圾收集时,必须暂停其他所有的工作线程(用户线程)。是Jvm client模式下默认的新生代收集器。对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
ParNew(并行GC)收集器
ParNew收集器其实就是serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为与Serial收集器一样。
Parallel Scavenge(并行回收GC)收集器
Parallel Scavenge收集器也是一个新生代收集器,它也是使用复制算法的收集器,又是并行多线程收集器。parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。吞吐量= 程序运行时间/(程序运行时间 + 垃圾收集时间),虚拟机总共运行了100分钟。其中垃圾收集花掉1分钟,那吞吐量就是99%。
Serial Old(串行GC)收集器
Serial Old是Serial收集器的老年代版本,它同样使用一个单线程执行收集,使用“标记-整理”算法。主要使用在Client模式下的虚拟机。
Parallel Old(并行GC)收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。
CMS(并发GC)收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。CMS收集器是基于“标记-清除”算法实现的,整个收集过程大致分为4个步骤:
①.初始标记(CMS initial mark)
②.并发标记(CMS concurrenr mark)
③.重新标记(CMS remark)
④.并发清除(CMS concurrent sweep)
其中初始标记、重新标记这两个步骤任然需要停顿其他用户线程。初始标记仅仅只是标记出GC ROOTS能直接关联到的对象,速度很快,并发标记阶段是进行GC ROOTS 根搜索算法阶段,会判定对象是否存活。而重新标记阶段则是为了修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间会被初始标记阶段稍长,但比并发标记阶段要短。
由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以整体来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。
CMS收集器的优点:并发收集、低停顿,但是CMS还远远达不到完美,器主要有三个显著缺点:
CMS收集器对CPU资源非常敏感。在并发阶段,虽然不会导致用户线程停顿,但是会占用CPU资源而导致引用程序变慢,总吞吐量下降。CMS默认启动的回收线程数是:(CPU数量+3) / 4。
CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure“,失败后而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行,伴随程序的运行自热会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在本次收集中处理它们,只好留待下一次GC时将其清理掉。这一部分垃圾称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,
即需要预留足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分内存空间提供并发收集时的程序运作使用。在默认设置下,CMS收集器在老年代使用了68%的空间时就会被激活,也可以通过参数-XX:CMSInitiatingOccupancyFraction的值来提供触发百分比,以降低内存回收次数提高性能。要是CMS运行期间预留的内存无法满足程序其他线程需要,就会出现“Concurrent Mode Failure”失败,这时候虚拟机将启动后备预案:临时启用Serial
Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数-XX:CMSInitiatingOccupancyFraction设置的过高将会很容易导致“Concurrent
Mode Failure”失败,性能反而降低。
最后一个缺点,CMS是基于“标记-清除”算法实现的收集器,使用“标记-清除”算法收集后,会产生大量碎片。空间碎片太多时,将会给对象分配带来很多麻烦,比如说大对象,内存空间找不到连续的空间来分配不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个-XX:UseCMSCompactAtFullCollection开关参数,用于在Full GC之后增加一个碎片整理过程,还可通过-XX:CMSFullGCBeforeCompaction参数设置执行多少次不压缩的Full GC之后,跟着来一次碎片整理过程。
G1收集器
G1(Garbage First)收集器是JDK1.7提供的一个新收集器,G1收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。还有一个特点之前的收集器进行收集的范围都是整个新生代或老年代,而G1将整个Java堆(包括新生代,老年代)。
垃圾收集器参数总结
-XX:+<option> 启用选项
-XX:-<option> 不启用选项
-XX:<option>=<number>
-XX:<option>=<string>
参数 |
描述 |
-XX:+UseSerialGC |
Jvm运行在Client模式下的默认值,打开此开关后,使用Serial + Serial Old的收集器组合进行内存回收 |
-XX:+UseParNewGC |
打开此开关后,使用ParNew + Serial Old的收集器进行垃圾回收 |
-XX:+UseConcMarkSweepGC |
使用ParNew + CMS + Serial Old的收集器组合进行内存回收,Serial Old作为CMS出现“Concurrent Mode Failure”失败后的后备收集器使用。 |
-XX:+UseParallelGC |
Jvm运行在Server模式下的默认值,打开此开关后,使用Parallel Scavenge + Serial Old的收集器组合进行回收 |
-XX:+UseParallelOldGC |
使用Parallel Scavenge + Parallel Old的收集器组合进行回收 |
-XX:SurvivorRatio |
新生代中Eden区域与Survivor区域的容量比值,默认为8,代表Eden:Subrvivor = 8:1 |
-XX:PretenureSizeThreshold |
直接晋升到老年代对象的大小,设置这个参数后,大于这个参数的对象将直接在老年代分配 |
-XX:MaxTenuringThreshold |
晋升到老年代的对象年龄,每次Minor GC之后,年龄就加1,当超过这个参数的值时进入老年代 |
-XX:UseAdaptiveSizePolicy |
动态调整java堆中各个区域的大小以及进入老年代的年龄 |
-XX:+HandlePromotionFailure |
是否允许新生代收集担保,进行一次minor gc后, 另一块Survivor空间不足时,将直接会在老年代中保留 |
-XX:ParallelGCThreads |
设置并行GC进行内存回收的线程数 |
-XX:GCTimeRatio |
GC时间占总时间的比列,默认值为99,即允许1%的GC时间,仅在使用Parallel Scavenge 收集器时有效 |
-XX:MaxGCPauseMillis |
设置GC的最大停顿时间,在Parallel Scavenge 收集器下有效 |
-XX:CMSInitiatingOccupancyFraction |
设置CMS收集器在老年代空间被使用多少后出发垃圾收集,默认值为68%,仅在CMS收集器时有效,-XX:CMSInitiatingOccupancyFraction=70 |
-XX:+UseCMSCompactAtFullCollection |
由于CMS收集器会产生碎片,此参数设置在垃圾收集器后是否需要一次内存碎片整理过程,仅在CMS收集器时有效 |
-XX:+CMSFullGCBeforeCompaction |
设置CMS收集器在进行若干次垃圾收集后再进行一次内存碎片整理过程,通常与UseCMSCompactAtFullCollection参数一起使用 |
-XX:+UseFastAccessorMethods |
原始类型优化 |
-XX:+DisableExplicitGC |
是否关闭手动System.gc |
-XX:+CMSParallelRemarkEnabled |
降低标记停顿 |
-XX:LargePageSizeInBytes |
内存页的大小不可设置过大,会影响Perm的大小,-XX:LargePageSizeInBytes=128m |
Client、Server模式默认GC
|
新生代GC方式 |
老年代和持久代GC方式 |
Client |
Serial 串行GC |
Serial Old 串行GC |
Server |
Parallel Scavenge 并行回收GC |
Parallel Old 并行GC |
wun/Oracle JDK GC组合方式
|
新生代GC方式 |
老年代和持久代GC方式 |
-XX:+UseSerialGC |
Serial 串行GC |
Serial Old 串行GC |
-XX:+UseParallelGC |
Parallel Scavenge 并行回收GC |
Serial Old 并行GC |
-XX:+UseConcMarkSweepGC |
ParNew 并行GC |
CMS
并发GC |
-XX:+UseParNewGC |
ParNew 并行GC |
Serial Old 串行GC |
-XX:+UseParallelOldGC |
Parallel Scavenge 并行回收GC |
Parallel Old 并行GC |
-XX:+UseConcMarkSweepGC |
Serial 串行GC |
CMS
并发GC |
1.对象优先在Eden分配
大多数情况下,对象在新生代Eden区分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
虚拟机提供了-XX:+PrintGCDetails这个收集器日志参数,告诉虚拟机在发送垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存区域分配情况。
2.大对象直接进入老年代
所谓的大对象是指,需要大量连续内存空间的java对象,最典型的大对象就是那种很长的字符串以及数组。大对象对虚拟机的内存分配来说就是一个坏消息(比遇到一个大对象更坏的消息就是遇到一群“朝生夕灭”的“短命大对象”,写程序的时候应该避免),经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”他们。
虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制。
3.长期存活的对象将进入老年代
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应发在新生代,哪些对象应放在老年代。为了做到这一点,虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1.对象在Survivor区中每“熬过”一次Minor GC,年龄就增加一岁,当它的年龄增加到一定程度(默认15岁),就将会被晋升到老年代。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold设置。
4.动态对象年龄判定
为了能更好的适应不同程序的内存状况,虚拟机并不是永远的要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到MaxTenuringThreshold中要求的年龄。
5.空间分配担保
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于或者HandlePromotionFailure设置为不允许冒险,那这时也要改为进行一次Full GC.
1.概述
4.空间分配担保
概述
JVM采用分代的垃圾回收策略:不同对象的生命周期是不一样的。目前JVM分代主要是分三个年代:
具体如下图所示:
对象优先在Eden分配
情况下,对象在新生代的Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
虚拟机提供了-XX:+PrintGCDetails这个收集日志参数,告诉虚拟机在发生垃圾收集行为时打印内存回收日志,并且在进程退出的时候输出当前的内存各区域分配情况。
使用示例代码来分析GC过程,具体代码如下:
package jvm;
import java.lang.String;
/**
* 执行的jvm参数:-verbose:gc -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
*/
class GarbageCollection {
public static final int ONEMB = 1024 * 1024;
public static void testAllocation() {
byte[] allocation1, allocation2, allocation3, allocation4;
allocation1 = new byte[2 * ONEMB];
allocation2 = new byte[2 * ONEMB];
allocation3 = new byte[2 * ONEMB];
allocation4 = new byte[4 * ONEMB];
System.out.println("OK!");
}
public static void main(String[] args) {
GarbageCollection.testAllocation();
}
}
运行结果:
OK!
Heap
PSYoungGen total 9216K, used 7344K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
eden space 8192K, 89% used [0x00000000ff600000,0x00000000ffd2c188,0x00000000ffe00000)
from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
to space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
ParOldGen total 10240K, used 4096K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
object space 10240K, 40% used [0x00000000fec00000,0x00000000ff000010,0x00000000ff600000)
PSPermGen total 21504K, used 2554K [0x00000000f9a00000, 0x00000000faf00000, 0x00000000fec00000)
object space 21504K, 11% used [0x00000000f9a00000,0x00000000f9c7e8a0,0x00000000faf00000)
通过-Xmx20M限定了Java堆大小为20M,-Xmn限定了新生代的大小为10M,剩下的10M分给老年代。同时,使用-XX:SurvivorRatio=8决定了新生代Eden区与一个Survivor区的空间比例是8:1,在运行结果中也能看到“eden space 8192K, from space 1024K, to space 1024K”,新生代总共可用的空间是9216K(Eden区+1个Survivor区的总容量)。
执行testAllocation()中分配allocation4对象的语句时会发生一次Minor GC,这次GC的结果是新生代6651KB变成148KB,而总内存占用量则几乎没有减少(因此allocation1、allocation2、allocation3三个对象都是存活的,虚拟机几乎没有任何可以回收的对象)。这次GC发生的原因是给allocation4分配内存的时候,发现Eden已经被占用了6MB,剩余空间不足以分配allocation4所需要的4M内存,因此发生了Minor GC。GC期间虚拟机又发现已有的3个2MB大小的对象全部无法收入Survivor空间,所以只好通过担保机制提前转移到老年代去。
这次GC结束后,4MB的allocation4对象顺利分配在Eden中,因此程序执行完成的结果是Eden占用4MB(被allocation4占用),Survivor空间,老年代被占用6MB(被allocation1、allocation2、allocation3占用)。通过GC日志也能说明这一点。
大对象直接进入老年代
所谓的大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。大对象对虚拟机的内存分配来说是一个坏消息,经常出现大对象容易导致内存还有不少空间的情况下就提前触发了GC以获取足够的连续空间来“安置”它们。
虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区以及两个Survivor区之间发生大量的内存复制。
示例代码如下:
package jvm;
import java.lang.String;
/**
* -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=1048576
*/
class GarbageCollection {
public static final int ONEMB = 1024 * 1024;
public static void testAllocation() {
byte[] allocation4;
allocation4 = new byte[4 * ONEMB]; // 直接在老年代分配
System.out.println("OK!");
}
public static void main(String[] args) {
GarbageCollection.testAllocation();
}
}
运行结果是:
OK!
Heap
PSYoungGen total 9216K, used 5296K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
eden space 8192K, 64% used [0x00000000ff600000,0x00000000ffb2c120,0x00000000ffe00000)
from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
to space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
ParOldGen total 10240K, used 0K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
object space 10240K, 0% used [0x00000000fec00000,0x00000000fec00000,0x00000000ff600000)
PSPermGen total 21504K, used 2554K [0x00000000f9a00000, 0x00000000faf00000, 0x00000000fec00000)
object space 21504K, 11% used [0x00000000f9a00000,0x00000000f9c7e888,0x00000000faf00000)
长期存活的对象将进入老年代
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代。为了做到这一点,虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁。当它的年龄增加到一定程度(默认是15岁),将会被晋升到老年代。对象晋升到老年代的阈值可以通过参数-XX:MaxTenuringThreshold设置。
空间分配担保
在发生Minor GC之前,虚拟机会先检查老年代最大可用连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC是确保安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,蒋尝试进行一次Minor GC,尽管这次GC是有风险的。如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。
“冒险”是冒了什么风险? 老年代的空间不一定能容纳青年代所有存活的对象,一旦不能容纳,那就还需要进行一次Full GC。
Minor GC and Full GC
Minor GC:从年轻代空间(包括Eden和Survivor区域)回收内存成为Minor GC。在发生Minor GC时候,有两处需要注意的地方:
Full GC:对整个堆进行整理,包括Young Generation、Old Generation、Permanent Generation。Full GC因为需要对整个区进行回收,所以比Minor GC要慢,因此应该尽可能减少Full GC的次数。
类加载过程
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。它们开始的顺序如下图所示:
其中类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。
这里简要说明下Java中的绑定:绑定指的是把一个方法的调用与方法所在的类(方法主体)关联起来,对java来说,绑定分为静态绑定和动态绑定:
静态绑定:即前期绑定。在程序执行前方法已经被绑定,此时由编译器或其它连接程序实现。针对java,简单的可以理解为程序编译期的绑定。java当中的方法只有final,static,private和构造方法是前期绑定的。
动态绑定:即晚期绑定,也叫运行时绑定。在运行时根据具体对象的类型进行绑定。在java中,几乎所有的方法都是后期绑定的。、
加载
加载是类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:
1、通过一个类的全限定名来获取其定义的二进制字节流。
2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3、在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。
注意,这里第1条中的二进制字节流并不只是单纯地从Class文件中获取,比如它还可以从Jar包中获取、从网络中获取(最典型的应用便是Applet)、由其他文件生成(JSP应用)等。
相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。
加载阶段完成后,虚拟机外部的 二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在Java堆中也创建一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据。
类加载器
类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类的加载阶段。对于任意一个类,都需要由它的类加载器和这个类本身一同确定其在就Java虚拟机中的唯一性,也就是说,即使两个类来源于同一个Class文件,只要加载它们的类加载器不同,那这两个类就必定不相等。这里的“相等”包括了代表类的Class对象的equals()、isAssignableFrom()、isInstance()等方法的返回结果,也包括了使用instanceof关键字对对象所属关系的判定结果。
站在Java虚拟机的角度来讲,只存在两种不同的类加载器:
站在Java开发人员的角度来看,类加载器可以大致划分为以下三类:
应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,我们还可以加入自定义的类加载器。因为JVM自带的ClassLoader只是懂得从本地文件系统加载标准的java class文件,因此如果编写了自己的ClassLoader,便可以做到如下几点:
1)在执行非置信代码之前,自动验证数字签名。
2)动态地创建符合用户特定需要的定制化构建类。
3)从特定的场所取得Java class,例如数据库中和网络中。
事实上当使用Applet的时候,就用到了特定的ClassLoader,因为这时需要从网络上加载java class,并且要检查相关的安全信息,应用服务器也大都使用了自定义的ClassLoader技术。
这几种类加载器的层次关系如下图所示:
这种层次关系称为类加载器的双亲委派模型。我们把每一层上面的类加载器叫做当前层类加载器的父加载器,当然,它们之间的父子关系并不是通过继承关系来实现的,而是使用组合关系来复用父加载器中的代码。该模型在JDK1.2期间被引入并广泛应用于之后几乎所有的Java程序中,但它并不是一个强制性的约束模型,而是Java设计者们推荐给开发者的一种类的加载器实现方式。
双亲委派模型的工作流程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。
使用双亲委派模型来组织类加载器之间的关系,有一个很明显的好处,就是Java类随着它的类加载器(说白了,就是它所在的目录)一起具备了一种带有优先级的层次关系,这对于保证Java程序的稳定运作很重要。例如,类java.lang.Object类存放在JDK\jre\lib下的rt.jar之中,因此无论是哪个类加载器要加载此类,最终都会委派给启动类加载器进行加载,这边保证了Object类在程序中的各种类加载器中都是同一个类。
验证
验证的目的是为了确保Class文件中的字节流包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。不同的虚拟机对类验证的实现可能会有所不同,但大致都会完成以下四个阶段的验证:文件格式的验证、元数据的验证、字节码验证和符号引用验证。
准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:
1、这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。
2、这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予的值。
假设一个类变量的定义为:
public static int value = 3;
那么变量value在准备阶段过后的初始值为0,而不是3,因为这时候尚未开始执行任何Java方法,而把value赋值为3的putstatic指令是在程序编译后,存放于类构造器<clinit>()方法之中的,所以把value赋值为3的动作将在初始化阶段才会执行。
下表列出了Java中所有基本数据类型以及reference类型的默认零值:
这里还需要注意如下几点:
3、如果类字段的字段属性表中存在ConstantValue属性,即同时被final和static修饰,那么在准备阶段变量value就会被初始化为ConstValue属性所指定的值。
假设上面的类变量value被定义为:
public static final int value = 3;
编译时Javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为3。回忆上一篇博文中对象被动引用的第2个例子,便是这种情况。我们可以理解为static final常量在编译期就将其结果放入了调用它的类的常量池中。
解析
解析阶段是虚拟机将常量池中的符号引用转化为直接引用的过程。在Class类文件结构一文中已经比较过了符号引用和直接引用的区别和关联,这里不再赘述。前面说解析阶段可能开始于初始化之前,也可能在初始化之后开始,虚拟机会根据需要来判断,到底是在类被加载器加载时就对常量池中的符号引用进行解析(初始化之前),还是等到一个符号引用将要被使用前才去解析它(初始化之后)。
对同一个符号引用进行多次解析请求时很常见的事情,虚拟机实现可能会对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标示为已解析状态),从而避免解析动作重复进行。
解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行,分别对应于常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info四种常量类型。
1、类或接口的解析:判断所要转化成的直接引用是对数组类型,还是普通的对象类型的引用,从而进行不同的解析。
2、字段解析:对字段进行解析时,会先在本类中查找是否包含有简单名称和字段描述符都与目标相匹配的字段,如果有,则查找结束;如果没有,则会按照继承关系从上往下递归搜索该类所实现的各个接口和它们的父接口,还没有,则按照继承关系从上往下递归搜索其父类,直至查找结束,查找流程如下图所示:
从下面一段代码的执行结果中很容易看出来字段解析的搜索顺序:
[java] view plain copy
14. }
16. class Child extends Father{
20. }
22. public class StaticTest{
26. }
class Super{
public static int m = 11;
static{
System.out.println("执行了super类静态语句块");
}
}
class Father extends Super{
public static int m = 33;
static{
System.out.println("执行了父类静态语句块");
}
}
class Child extends Father{
static{
System.out.println("执行了子类静态语句块");
}
}
public class StaticTest{
public static void main(String[] args){
System.out.println(Child.m);
}
}
执行结果如下:
执行了super类静态语句块
执行了父类静态语句块
33
如果注释掉Father类中对m定义的那一行,则输出结果如下:
执行了super类静态语句块
11
另外,很明显这就是上篇博文中的第1个例子的情况,这里我们便可以分析如下:static变量发生在静态解析阶段,也即是初始化之前,此时已经将字段的符号引用转化为了内存引用,也便将它与对应的类关联在了一起,由于在子类中没有查找到与m相匹配的字段,那么m便不会与子类关联在一起,因此并不会触发子类的初始化。
最后需要注意:理论上是按照上述顺序进行搜索解析,但在实际应用中,虚拟机的编译器实现可能要比上述规范要求的更严格一些。如果有一个同名字段同时出现在该类的接口和父类中,或同时在自己或父类的接口中出现,编译器可能会拒绝编译。如果对上面的代码做些修改,将Super改为接口,并将Child类继承Father类且实现Super接口,那么在编译时会报出如下错误:
StaticTest.java:24: 对 m 的引用不明确,Father 中的
变量 m 和 Super 中的
变量 m
都匹配
System.out.println(Child.m);
^
1 错误
3、类方法解析:对类方法的解析与对字段解析的搜索步骤差不多,只是多了判断该方法所处的是类还是接口的步骤,而且对类方法的匹配搜索,是先搜索父类,再搜索接口。
4、接口方法解析:与类方法解析步骤类似,知识接口不会有父类,因此,只递归向上搜索父接口就行了。
初始化
初始化是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的Java程序代码。在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源,或者可以从另一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程。
这里简单说明下<clinit>()方法的执行规则:
1、<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句中可以赋值,但是不能访问。
2、<clinit>()方法与实例构造器<init>()方法(类的构造函数)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此,在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。
3、<clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
4、接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作,因此接口与类一样会生成<clinit>()方法。但是接口与类不同的是:执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
5、虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。
下面给出一个简单的例子,以便更清晰地说明如上规则:
[java] view plain copy
10. }
12. public class ClinitTest{
16. }
class Father{
public static int a = 1;
static{
a = 2;
}
}
class Child extends Father{
public static int b = a;
}
public class ClinitTest{
public static void main(String[] args){
System.out.println(Child.b);
}
}
执行上面的代码,会打印出2,也就是说b的值被赋为了2。
我们来看得到该结果的步骤。首先在准备阶段为类变量分配内存并设置类变量初始值,这样A和B均被赋值为默认值0,而后再在调用<clinit>()方法时给他们赋予程序中指定的值。当我们调用Child.b时,触发Child的<clinit>()方法,根据规则2,在此之前,要先执行完其父类Father的<clinit>()方法,又根据规则1,在执行<clinit>()方法时,需要按static语句或static变量赋值操作等在代码中出现的顺序来执行相关的static语句,因此当触发执行Father的<clinit>()方法时,会先将a赋值为1,再执行static语句块中语句,将a赋值为2,而后再执行Child类的<clinit>()方法,这样便会将b的赋值为2.
如果我们颠倒一下Father类中“public static int a = 1;”语句和“static语句块”的顺序,程序执行后,则会打印出1。很明显是根据规则1,执行Father的<clinit>()方法时,根据顺序先执行了static语句块中的内容,后执行了“public static int a = 1;”语句。
另外,在颠倒二者的顺序之后,如果在static语句块中对a进行访问(比如将a赋给某个变量),在编译时将会报错,因为根据规则1,它只能对a进行赋值,而不能访问。
总结
整个类加载过程中,除了在加载阶段用户应用程序可以自定义类加载器参与之外,其余所有的动作完全由虚拟机主导和控制。到了初始化才开始执行类中定义的Java程序代码(亦及字节码),但这里的执行代码只是个开端,它仅限于<clinit>()方法。类加载过程中主要是将Class文件(准确地讲,应该是类的二进制字节流)加载到虚拟机内存中,真正执行字节码的操作,在加载完成后才真正开始。
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构。它是虚拟机运行时数据区的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。
在编译程序代码时,栈帧中需要多大的局部变量表、多深的操作数栈都已经完全确定。并且写入到了方法表的Code属性之中,因此,一个栈需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
一个线程中方法的调用链可能会很长,很多方法同时处于执行状态,对于执行引擎来说,只有栈顶的栈帧是有效的。
一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。以变量槽(slot)为基本单位。每个slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,其中reference是对象的引用,可以查到对象在java堆中的起始地址索引和方法区中的对象数据类型。returnAddress是为字节码指令jsr、jsr_w和ret服务的,它指向了一条字节码指令的地址。
虚拟机通过索引定位的方式使用局部变量表,若是32位数据,索引n代表使用第n个slot,若是64位,则使用第n和n+1个slot。
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用。持有这个引用是为了支持方法调用过程中的动态连接。Class文件的常量池中有你大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另一部分将在每一次的运行期间转化为直接应用,这部分称为动态连接。
方法调用阶段唯一的任务就是确定被调用方法的版本(即调用了哪一个方法)。Class文件的编译过程不包含传统编译的连接步骤,所有方法调用中的目标方法在Class文件里面存储的都是一个常量池中的符号引用,而不是方法在实际运行时内存布局中的入口地址。这一特性给java带来了更加强大的动态扩展能力。
解析:
调用目标在程序代码写好、编译器进行变异的时候就必须确定下来,这类方法的调用称为解析。在java中符合“编译期可知、运行期不可变”的方法有静态方法和私有方法,两者都不可能通过继承或别的方式重写出其他版本,因此他们都适合在类加载阶段进行解析。
分派:
静态分派:
但为什么会选择执行参数为Human的重载呢?在这之前,先按如下代码定义两个重要的概念:
Human man
= new Man();
上面代码中的“Human”称为变量的静态类型(Static Type)或者外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是编译期可知的;而实际类型变化的结果在运行期才可确定,编译期在编译程序的时候并不知道一个对象的实际类型是什么?如下面的代码:
解释了这两个概念,再回到上述代码中。main()里面的两次sayHello()方法调用,在方法接收者已经确定是对象“sr”的前提下,使用哪个重载版本,就完全取决于传入参数和数据类型。代码中刻意定义了两个静态类型相同,实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型在编译期是可知的,所以在编译阶段,Javac编译器就根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法的两条invokevirual指令的参数中。
所有依赖静态类型来定位方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动力实际上不是由虚拟机来执行的。
编译器虽然能确定出方法的重载版本,但是很多情况下,这个重载版本并不是“唯一的”,往往只能确定一个“更适合的”版本。如sayHello(‘a‘);找重载方法时顺序:sayHello(char arg) --> sayHello(int arg) --> sayHello(long arg)
--> sayHello(float arg) --> sayHello(double arg) --> sayHello(Character arg) --> sayHello(Serializable arg)
--> sayHello(Object arg) --> sayHello(char... arg)
因为java.lang.Serializable是java.lang.Character实现的一个接口,同时java.lang.Character还实现了一个java.lang.Comparable<Character>接口,若同时出现两个参数分别为Serializable和Comparable<Character>的重载方法,他们的优先级相同,编译器无法为确定要转换为哪个类型,会提示模糊,拒绝编译。此时程序必须在调用时显示的制定字面量的静态类型,如sayHello((Comparable<Character>) ‘a‘)。
动态分配:
我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
1.1.1动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。我们知道Class文件的常量池有存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接。
1.1.2方法返回地址
当一个方法被执行后,有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。
另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。
无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
1.1.3附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。
Java内存模型
JMM(Java Memory Model)
简而言之就是:主内存+工作内存+这两种内存间的交互+三大特性
主内存(线程共享)+工作内存(线程私有)
工作内存和主内存交互,Java提供了8大指令
lock, unlock, read, load, use, assign, store, write
总结:从主内存到工作内存,顺序执行read, load操作,不一定连续执行,只是顺序执行。同理从工作内存到主内存,顺序执行store, write操作。
这8大指令的操作,必须满足如下8大规则:
以上是对于普通变量的8大规则,对于volatile变量,还有如下三个规则:
volatile额外三大规则
JMM三大特性:
到这里可以稍微总结一下:
final保证可见性,不保证原子性和有序性
volatile保证可见性和有序性,不保证原子性
synchronized保证原子性,可见性和有序性
先行并发原则(happens-before)
如果java代码所有的有序性,都靠volatile和synchronized完成,那么就太繁琐了。幸亏Java提供了先行并发原则,它是判断数据是否存在竞争,线程是否安全的主要依据。依靠这个原则,我们可以通过几个规则来一揽子解决并发环境下两个操作是否可能存在冲突的所有问题。
Java天然的8大先行发生关系,即无需任何同步就实现了。
一个操作“时间上先发生”不代表这个操作“先行发生”。同理“先行发生”无法推导出“时间上先发生”
例子:线程A比B先启动,但B可能先行发生相应操作。
Java与线程
线程实现,三种方式
Java如何实现的呢?
根据操作系统决定,操作系统支持什么样的线程模型,java虚拟机就支持什么样线程模型。
线程调度
协同式线程调度,抢占式线程调度
java采用的是抢占式线程调度,每个线程都有相应的优先级。
但优先级不太靠谱:不同平台支持的优先级数目不同,java提供了10个,而windows只支持7个,这样一些java不同的优先级会在windows中变得相同。此外,windows中存在优先级推进器,即使该线程优先级不高,但是它表现的非常非常的努力,可能就会跨越优先级去为它分配时间。
线程状态
java定义了5种线程状态
原文:https://www.cnblogs.com/wangchaonan/p/10731459.html