JAVA的技术体系:支撑JAVA程序运行的虚拟机,提供各开发领域接口的JAVA API, JAVA编程语言及许多第三方框架(spring,struts)
对于JAVA程序员,在虚拟机自动内存管理机制帮助下,不再需要为每一个new操作去写配对的delete/free代码。不容易出现内存泄露与内存溢出的问题。一旦出现了,不了解虚拟机如何使用内存,排查错误将是非常困难的。
JAVA虚拟机在执行JAVA程序的时候会把它所管理的内存分为若干个不同的数据区域,有的区域随虚拟机进程的启动而存在,有些区域则依赖于用户线程的启动和结束而建立和销毁。
运行时数据区域:方法区,虚拟机栈,本地方法栈(native method stack),堆,程序计数器。
程序计数器:较小的内存空间,当前线程所执行的字节码的行号指示器。每条线程都需要一个独立的程序计数器。
java虚拟机栈:线程私有。生命周期与线程相同。虚拟机栈描述的是java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈等信息。每一个方法从调用直至执行完成的过程,就对应一个栈帧在虚拟机栈中从入栈到出栈的过程。经常有程序员把java内存区分为堆内存与栈内存,分法粗糙,这里的栈内存指java虚拟机栈,或者说栈中的局部变量表部分。局部变量表存放了编译期可知的各种基本数据类型以及对象引用。局部变量表所需要的内存空间在编译期间完成完配。当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的。方法运行期间不会改变局部变量表的大小。两种异常:stackoverflow:线程请求的栈深度大于虚拟机所允许的深度(申请地址超过堆栈大小)。如何虚拟机可以动态扩展,如果扩展时无法申请到足够的内存。就会抛出outofmemory.
本地方法栈:为虚拟机使用到的Native方法服务。与虚拟机栈所发挥的作用相似。
堆:内存最大,被所有线程共享。在虚拟机启动时创建,此内存区域唯一目的就是存放对象实例 。所有的对象实例以及数组都要在堆上分配。java堆是垃圾收集器管理的主要区域,java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,当前主流的虚拟机都是按照可扩展来的(-Xmx)。如果在堆中没有内存完成实例分配,并且堆也无法扩展时,抛出outofmemoryerror.
方法区:各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、静态变量、常量、代码等数据。java虚拟机规范对方法区的限制非常宽松,不需要连续的内存,可选择固定或可扩展,还可以选择不实现垃圾收集,这区域的内存回收目标主要是针对常量池的回收及对类型的卸载。当方法区无法满足内存分配需求时,抛出outofmemory异常。
常量池:存放编译期生成的各种字面量和符号引用。运行时常量池相对于Class文件常量池的另外一个重要特性是具备动态性。运行期间也可能将新的常量加入池中,这种特性被开发人员利用地较多的是String的intern()方法。当常量池无法再申请到内存时会抛出outofmemory异常。
直接内存:NIO引入了一种基于channel与buffer的IO方式,它可以直接使用native函数库分配堆外内存。然后通过一个存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,避免了在java堆和native堆中来回复制数据。本机直接内存不会受到java堆大小的限制,但会受到本机总内存以及处理器寻址空间的限制。
对象创建:虚拟机遇到一条new指令时,首先检查这个指令的参数能否在常量池中定位到一个类的符号引用,并且检查这个符号引用所代表的类是否已经被加载、解析和初始化,如果没有,则必须先执行类加载过程,在类加载检查通过后,为新生对象分配内存,等同于把一块确定大小的内存从java堆中划分出来。假设java堆中内存是绝对规整的,所有用过的内存放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,分配内存好是把指针向空闲空间那边挪动一段与对象大小相同的空间,这种分配方式称为“指针碰撞”,如果java中的内存并不是规整的,已使用的内存和空闲的内存相互交错,虚拟机必须维护一个列表,这种分配方式称为“空闲列表”,选择哪种分配方式由java堆是否规整决定,而java堆是否规整又由采用的垃圾收集器是否带有压缩整理功能决定。在并发情况下,创建对象并不是线程安全的,可能出现正在给对象A分配内存,指针还没有修改,对象B又同时使用了原来的指针分配内存。1.虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。2.把内存分配的动作按照线程划分到不同的空间执行。即每个线程预告分配一小块内存,称为本地线程分配缓冲(TLAB),虚拟机是否使用TLAB,通过: -XX:+/-UseTLAB参数设定。内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值,接下来设置对象头信息。执行方法,把对象按照程序员的意愿进行初始化。
对象的内存布局:对象在内存中存储的布局可以分为三个部分:对象头,实例数据,对齐填充(占位符,对象的大小必须是8字节的整数倍)。对象头的一部分用于存储对象自身的运行时数据如哈希码、GC分代年龄,锁状态标志等。另一部分是指向它的类元数据的指针。如果对象是一个java数组,那在对象头中还必须有一块用于记录数组长度的数据。
对象的访问定位:主流的访问方式有使用句柄和直接指针访问两种方式。如果使用句柄访问的话,堆中将划分出一块内存作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的地址信息。如果使用直接指针访问,则reference中存储的直接就是对象地址。使用直接指针方式访问的最大好处就是速度更快,它节省了一次指针定位的时间开销。使用句柄最大的好处就是reference中存放的是稳定的句柄地址,在对象被移动时只更改句柄中的实例数据指针,而reference本身不需要更改。
如果是建立多线程导致的内存溢出,在不能减少线程数的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。这种通过减少内存的方式来解决内存溢出的问题比较难想到。
本地直接内存溢出:DirectMemory容量可通过-XX:MaxDirectMemorySize指定,如果不指定,默认与java堆最大值一样。
第三章:垃圾收集器与内存分配策略
程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,这几个区域就不需要过多考虑回收的问题,因为方法结束或线程结束时,内存自然就跟随着回收了。而java堆和方法区则不一样,这部分内存的分配和回收都是动态的。
垃圾收集器在对堆进行回收前,第一件事就是确定这些对象中哪些已经死去?
引用计数算法:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1,当引用失效时,计数器值就减1.任何时刻计数器为0的对象就是不再被使用的对象。客观说,引用计数器算法实现简单,判定效率也很高。但java虚拟机里面没有使用引用计数算法,最主要的原因是它很难解决对象之间相互引用的问题。即:两个对象相互引用,但再无其它引用,且不可能被访问
可达性分析算法:通过一系列的称为"GC Roots"的对象作为起始点,从这些起点开始向下搜索,搜索所走过的路径称为引用链。当一个对象到"GC Roots"没有任何引用链相连,则证明此对象是不可用的。java中"GC Roots"中的对象包括几种:虚拟机栈中(本地变量)引用的对象,方法区中类静态属性引用的对象,方法区中常量引用的对象。
java对引用的划分:强引用,软引用,弱引用,虚引用。
强引用:类似Object obj = new Object();只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象。
软引用:描述有用但并非必需的对象,在系统发生内存异常之前将会回收这些对象。
弱引用:只能生存到下一次垃圾收集发生之前,虚引用的唯一目的就是在这个对象被收集器回收时会收到一个系统通知。
即使在可达性分析中不可达的对象,也并非“非死不可”,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与"GC Roots"相连的引用链,便会进行第一次标记并进行一次筛选,筛选的条件是是否有必要执行finalize()方法。当对象没有覆盖finalize()方法或其已经被虚拟机调用过,则虚拟机会认为“没有必要执行”,如果有必要执行"finalize()"方法,这个对象会被放置在一个叫做"F-Queue"的队列,并会被一个低优先级的线程执行,线程会触发这个方法,但不保证等待它运行结束。finalize()方法是对象逃脱死亡命运的最后一次机会,之后GC会进行第二次标记,如果对象不想被回收,只要在这个方法中与引用链上任何一个对象建立关系取可,把自己赋值给某个类变量或对象的成员变量。任何一个对象的finalize()方法只会被执行一次,如果面临下次回收,该方法不会被再次执行。
finalize()方法尽量避免使用它。它的运行代价高昂,且不确定性极大。
方法区的垃圾回收主要回收:废弃常量和无用的类(条件苛刻,所有实例已经被回收,ClassLoader已经被回收,无法在任何地方通过反射访问该方法)。
垃圾收集算法:标记-清除算法:不足:效率问题,标记和清除两个过程的效率都不高。空间问题:产生大量的内存碎片。当前商业虚拟机的垃圾收集都采用“分代收集算法”,一般把java堆分为老年代和新生代。根据各个年代特点采用最适合的算法,在新生代中,每次垃圾收集都发现有大批对象死去,只有少量存活,就采用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为存活率高,没有额外空间对它进行分配担保,就必须采用“标记-清理”或“标记-整理”算法进行回收。
内存分配与回收策略:自动内存管理最终可归结为自动化的解决了两个问题:给对象分配内存以及回收分配给对象的内存。大多数情况下,对象在新生态Eden区中分配。
Minor GC:指发生在新生代的垃圾收集动作。因为大部分对象具备朝生夕灭的特征,所以Minor GC非常频繁,一般回收速度也比较快。
所谓的大对象,是指需要大量连续内存空间的JAVA对象,最典型的大对象就是那种很长的字符串以及数组。大对象对虚拟机的内存分配来说是一个坏消息, 更加坏的消息是遇到一群短命的大对象。写程序时应当尽力避免。大对象直接在老年代分配,以避免在新生代分配后采用复制算法回收内存时产生大量的内存复制。
长期存活的对象进入老年代,在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,Minor GC可以确保是安全的。如果不成立,虚拟机会查看是否允许担保失败,如果允许,会继续检查老年代的连续空间是否大于历次晋升到老年代对象的平均水平,如果大于,则允许冒险,尝试进行一次Minor GC,否则要改为进行一次Full GC(老年代的垃圾回收)
内存回收与垃圾收集器在很多时候都是影响系统性能、并发能力的主要因素之一,虚拟机之所以提供多种不同的收集器以及大量的调节参数,是因为只有根据实际应用需求、实现方式选择最优的调节方式。
虚拟机性能监控与故障处理工具::给一个系统定位问题的时候,知识、经验是关键基础,数据是依据,工具是运用知识处理数据的手段。这里的数据包括:运行日志、异常堆栈、GC日志、线程快照(threaddump/javacore文件),堆转储快照(heapdump/hprof文件).
JDK的命令行工具:::jps 显示指定系统内所有的虚拟机进程,。功能:可以列出正在运行的虚拟机进程,并显示虚拟机执行主类名称以及这些进程的本地虚拟机唯一ID(LVMID),LVMID与操作系统的进程ID是一致的。jps工具主要参数选项。-q只输出LVMID,省略主类的名称。-l输出主类的全名或jar路径。-m输出虚拟机进程启动时传递给主类main()函数的参数,-v输出虚拟机进程启动时JVM参数.
jstat 用于收集虚拟机各方面的运行数据,它可以显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、JIT编译等运行时数据,jstat工具特别强大,有众多的可选项,详细查看堆内各个部分的使用量,以及加载类的数量。使用时,需加上查看进程的进程id,和所选参数。具体百度。
jinfo 显示虚拟机配置信息
jmap 生成虚拟机的内存转储快照(heapdump文件),除了生成dump文件的-dump选项和用于查看每个类的实例、空间占用统计的-histo在所有操作系统通用外,其它命令限于linux
jhat 用于分析heapdump文件,它会建立一个HTTP服务器,让用户可以在浏览器上查看分析结果
jstack 显示虚拟机的线程快照,在Thread类中有一个getAllStackTraces()方法用于获取虚拟机中所有线程的StackTraceElement对象,使用这个方法可以只通过简单的几行代码就完成jstack的绝大部分功能。在实际项目中不妨调用这个方法做成管理员界面,可以随时使用浏览器查看线程堆栈。
1. //服务器线程信息
2. for (Map.Entry stackTrace : Thread.getAllStackTraces().entrySet()) {
3. Thread thread = stackTrace.getKey();
4. StackTraceElement[] stack = stackTrace.getValue();
5. if (thread.equals(Thread.currentThread())) {
6. continue;
7. }
8. System.out.println("线程:"+ thread.getName());
9. for (StackTraceElement stackTraceElement : stack) {
10. System.out.println(stackTraceElement);
11. }
12. }
JDK除了提供大量的命令行工具之外,还提供两个强大的可视化分析工具,JConsole和VisualVM。
jconsole是一种基于JMX的可视化监控管理工具,通过jdk/bin目录下的jconsole.exe启动。将自动搜索出本机所有的虚拟机进程,不需要用户自己再使用jps查询了。双击选择其中一个进程即可开始监控。其中的内存标签页相当于调用jstat命令,线程标签页相当于调用jstack命令,线程长时间停顿的主要原因有:数据库连接,网络资源,设备资源,死循环,锁等待。
visualVM到目前为止随JDK发布的功能最强大的运行监视和故障处理程序,在jdk/bin目录下:jvisualvm.exe。不给visualVM装任何插件,就相当于放弃了它最精华的功能,和没有安装任何应用软件的操作系统一样。可以点击工具->插件自动安装插件。BTrace是一个很有趣的插件,它的作用是在不停止目标程序运行的情况下,动态加入原本并不存在的调试代码。在visualVM中安装了BTrace插件后,在应用程序面板右键点击要调试的程序,会出现“trace application”菜单,点击后进入到我们的插件界面中。
例如我们运行如下程序:
1. public class test01 {
2. public static void main(String[] args) throws IOException {
3. test01 test = new test01();
4. BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
5. for (int i = 0; i < 10; i++) {
6. br.readLine();
7. int a = 100;
8. int b = 100;
9. test.add(a, b);
10. }
11. }
12. public int add(int a,int b){
13. return a + b;
14. }
15. }
当程序运行后,进入到BTrace界面,输入如下脚本:
@BTrace
public class TracingScript {
/* put your code here */
@OnMethod(
clazz = "test2.test01",
method = "add",
location = @Location(Kind.RETURN)
)
public static void func(int a,int b,@Return int result){
println("调用堆栈:");
jstack();
println(strcat("方法参数a",str(a)));
println(strcat("方法参数b",str(b)));
println(strcat("方法结果",str(result)));
}
}
点击start编译完成后,在程序中继续运行时便可看到我们加入的调试信息,非常方便。可以在不停止目标程序的情况下调试程序。另外,BTrace可以完成很多功能。具体到官网查看.
第五章,调优案例分析与实战
案例分析:1.高性能硬件上的部署策略:::一个15万PV/天左右的在线文档类型网站最近更换了硬件系统,新硬件为4个CPU,16G内存,操作系统为64位centos,resin作为web服务器。整个服务器暂时没有部署别的应用,所有资源都可以提供给这个访问不太多的网站使用。管理员选择了64位JDK,并通过-Xms,-Xmx参数将堆固定为12G,使用一段时间后发现效果不理想,经常出现长时间失去响应的情况。监控服务器运行状况后发现停顿是由GC导致的,虚拟机运行在server模式,默认使用吞吐量优先收集器,回收12GB内存,一次停顿在14秒左右。并由于程序设计原因,文档从磁盘读入内存,导致内存中出现很多由文档序列化产生的大对象,这些大对象很多都进入了老年代。由此导致内存不够用,经常发生GC现象。
在高性能硬件上部署程序时,主要有两种方式:1通过64位JDK使用大内存。2使用若干个32位虚拟机建立逻辑集群来利用硬件资源。此案例中的管理员采用了第一种方式,对于用户交互性强、对停顿时间敏感的系统,可以给java虚拟机分配超大堆的前提是保证应用程序的FULL GC频率控制的足够低。至少低到不会影响用户使用,例如一天出现一次。控制FULL GC频率的关系是看系统中绝大多数对象是否符合“朝生夕灭”的特征,不能有成批量、长生存时间的大对象产生,这样才能保证老年代的稳定。在大多数应用中,主要对象的生存周期都是请求级的或者页面级的,全局级的长生命对象很少。这样使用超大内存,网站响应速度才会有保障。除此之外,如果使用64位JDK管理大内存还会出现很多问题:内存回收导致长时间停顿,性能低于32位,需要保证程序稳定,内存消耗大。
使用若干个32位虚拟机建立逻辑集群来利用硬件资源,具体是在一个物理机器上启动多个应用服务器进程,每个服务器进程分配不同端口,在前端搭建一个负载均衡器,以反向代理的方式分配访问请求,考虑到在一台物理机器上建立逻辑集群仅仅是为了利用硬件资源,并不需要关心高可用性需求,因此使用无session复制的亲合式集群是一个不错的选择,即均衡器按照一定的算法将一个固定的用户请求永远分配到一个固定的集群结点处理。这样的逻辑集群也会有很多问题,没有什么是足够好的。
最后的解决方案是:建立5个32位逻辑集群,每个进程按2GB计算,其中1.5G为堆内存,另建立一个apache服务器作为负载均衡。考虑到用户对响应速度关心,且主要压力集中在内存和磁盘。改为CMS收集器进行垃圾回收。
集群间同步导致的内存溢出:缺陷是这一类被集群共享的数据要使用JBossCache这种集群缓存来同步的话,可以允许读操作频繁,因为数据在本地内存有一份副本,读取的动作不会耗费多少资源,但入频繁的话会耗费大量的网络同步开销。
堆外内存导致的溢出异常,NIO操作需要使用到的Direct Memory内存由于大量内存分配给堆,导致它发生异常。除了java堆和永久代以外,下面这些区域还会占用较多内存,这里所有内存受到操作系统进程最大内存的限制。Direct memory,线程堆栈,Socket缓冲区:每个Socket连接都Receive和Send两个缓冲区等。
外部命令导致系统缓慢:每个用户请求的处理都需要执行一个外部shell脚本来获得系统的一些信息,执行这个shell脚本是通过java的Runtime.getRuntime().exec()方法调用,这种调用方式可以达到目的,但是它在java虚拟机中是非常消耗资源的操作,java虚拟机执行这个命令的过程是:克隆一个和当前虚拟机拥有一样环境变量的进程,再用这个新的进程去执行命令,最后退出这个进程。如果频繁执行这个操作,系统的消耗会非常大。不仅是cpu,内存负担也重。
服务器JVM进程崩溃,由于MIS系统的用户多,待办事项变化很快,为了不被OA系统速度拖累,使用了异步的方式调用WEB服务。但由于两边服务的速度不对等,时间越长就累积了越多的WEB服务没有完成,导致在等待的线程和socket连接越来越多,最终超过虚拟机的承受能力而使进程崩溃。解决办法:通知OA门户修复无法使用的接口,改用生产者/消费者队列代替异步调用。
不恰当数据结构导致内存占用过大:有一个后台RPC服务器,使用64位虚拟机,内存配置为:-Xms4g -Xmx8g -Xmn1g。平时对外服务的Minor GC时间约为30ms以内,完全可以接受。但业务上需要加载一个约800MB的数据文件到内存进行数据分析,这些数据会在内存中形成超过约100万个HashMap entry。在这段时间内Minor GC会造成500ms的停顿。这个停顿接受不了。这里的问题产生的根本原因是用HashMap结构来存储数据文件空间效率极低。在这个数据结构当中,只有key和value两个长整型数据是有效数据,而把它们封装成Long类然后又封装到HashMap中,使得空间利用率只有18%。
由Windows虚拟内存导致的长时间停顿:
实战,Eclipse运行速度调优:::
虚拟机执行子系统:
类加载机制::::::类从加载到虚拟机内存,到卸载出内存,整个生命周期包括:加载,连接(验证,准备,解析),初始化,使用,卸载。其中,加载,验证,准备,初始化,卸载这五个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班的开始()。而解析阶段则不一定,在某些情况下,它可以在初始化阶段之后再开始。这是为了支持java运行时的动态绑定(运行时绑定)。这里是按部就班的开始,而不是按部就班的进行或完成。因为这些阶段通常都是相互交叉式的混合进行的。通常会在一个执行阶段中调用、激活另外一个阶段。
初始化:有且只有5种情况必须立即对类进行初始化(而加载,验证,准备自然需要在此之前开始)::使用new实例化对象的时候。读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池中的静态字段除外),以及调用一个类的静态方法的时候。2.使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有初始化,则要先触发其初始化。3.当初始化一个类的时候,如果发现其父类还没有初始化,则需要先进行其父类的初始化。4.当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机会先初始化这个主类。这5种行为称为对一个类进行主动引用,除此之外,所有引用类的方式都不会触发其初始化,称为被动引用。如下例所示:
//通过子类引用父类的静态字段,不会导致子类初始化
1. public class test01 {
2. public static void main(String[] args){
3. System.out.println(SubClass.value);
4. }
5. }
6. class SuperClass{
7. static{
8. System.out.println("super class init");
9. }
10. public static int value = 123;
11. }
12. class SubClass extends SuperClass{
13. static{
14. System.out.println("sub class init");
15. }
16. }
上述代码运行后,只会输出“super class init”,不会输出“sub class init”。对于静态字段,只有直接定义这个字段的类才会被初始化,因此通过子类来引用父类中定义的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
1. //通过数组定义来引用类,不会触发此类的初始化
2. public class test01 {
3. public static void main(String[] args){
4. SuperClass[] classes = new SuperClass[10];
5. }
6. }
7. class SuperClass{
8. static{
9. System.out.println("super class init");
10. }
11. public static int value = 123;
12. }
这段代码并没有触发SuperClass的初始化,却触发了另外一个类的初始化,它是由虚拟机自动生成直接继承Object的子类,创建动作由字节码指令newarray触发。这个类代表了一个元素类型为SuperClass的一维数组,数组中应用的属性和方法都被实现在这个类中。
1. //常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
2. public class test01 {
3. public static void main(String[] args){
4. System.out.println(SuperClass.STRING);
5. }
6. }
7. class SuperClass{
8. static{
9. System.out.println("super class init");
10. }
11. public static final String STRING = "hello world";
12. }
虽然在java源码中引用了superClass的常量,但在编译阶段通过常量传播优化,已经将此常量的值存储到了test01的常量池中,这两个类在编译成class之后就不存在任何关系了。
接口中也有初始化过程,用于初始化接口中所定义的成员变量,当一个类在初始化时,要求其父类全部都已经初始化过了。但是一个接口在初始化时,并不要求其父接口都已经完成初始化,只有真正使用到父接口的时候才需要初始化。
类加载全过程:
加载:1.通过一个类的全限定名来获取此类的二进制流。2.将这个类所代表的静态存储结构转换为方法区的运行时数据结构。3.在内存中生成一个java.lang.Class对象代表这个类,作为方法区这个类的各种数据的访问入口。在第一点中,并没有指出要怎么获取以及从哪获取二进制流,例如:可以从ZIP包中读取,最终成为日后JAR,WAR包的基础。从网络中获取。最典型的场景就是Applet。运行时计算生成,这种场景使用最多的就是动态代理技术。在java.lang.reflect.Proxy中,就是用了ProxyGerator.generateProxyClass来为特定接口形成形式为“$proxy”的代理类的二进制字节流。还可以由其它文件生成,如 JSP所生成的class类,甚至可以从数据库中读取。
对于数组类而言,情况有所不同,数组类本身不通过类加载器创建。它是由虚拟机直接创建的,如果数组类的组件类型是引用类型,数组类将在加载该组件类型的类加载器的名称空间上被标识(一个类必须与类加载器一起确定唯一性)。如果数组C的组件类型不是引用类型,虚拟机将会把数组类标记为与引导类加载器关联。数组类的可见性与它的组件类的可见性一致。
验证是连接阶段的第一步,这个阶段的目的是确保Class文件的字节流包含的信息符合当前虚拟机的要求。并且不会危害虚拟机自身的安全。准备阶段是正式为类变量分配内存并设置类变量初始值的部分。这些变量所使用的内存都将在方法区中进行分配。这个时候进行内存分配的仅包括静态成员变量,而不包括实例变量。实例变量将会在对象实例化时随对象一起分配到堆中。这里的初始值是数据类型的零值。另外,只是在通常情况下,会初始化为零值。仍然会有一些特殊情况:如常量:public static final int value = 123;在准备阶段就会将value值设置为123
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,引用的目标并不一定加载到内存中。直接引用可以是直接指向目标的指针、相对偏移或是一个能间接定位到目标的句柄。直接引用的目标必定已经在内存中存在。
类初始化:执行类构造器()方法的过程。()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{})中的语句合并产生的。编译器收集的顺序是由语句在源文件中出现的顺序决定的。静态语句块只能访问到定义在它之前的静态变量。()方法与类的构造函数不同,虚拟机保证在子类的()方法执行之前,父类的()已经执行完毕,因此在虚拟机中第一个执行()方法的类是Object类。由于父类的()方法先执行,也就意味着父类的静态语句块要优先于子类的变量赋值动作。()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也就没有对变量的赋值操作。那么编译器可以不为这个类生成()方法,接口与类不同的是,执行接口的()方法并不要求先执行父接口的()方法。虚拟机会保证一个类的()方法在多线程环境中被正确的加锁同步。如果有多个线程去初始化这个类,那么只会有一个线程去执行这个类的()方法。同一个类加载器下,一个类型只会初始化一次。
类加载器:虚拟机设计团队把类加载阶段中的“ 通过一个类的全限定名来获取这个类的二进制字节流”这个动作放到虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类,实现这个动作的代码模块称为“类加载器。……类加载器在类层次划分,OSGI,热部署,代码加密等领域大放异彩。类加载器与类一起确定其在虚拟机中的唯一性,比较两个类是否相等,只有在两个类在同一个类加载器加载的前提下才有意义。
InputStream is = getClass().getResourceAsStream("");
byte[] b = new byte[is.available()];
is.read(b);
从java虚拟机的角度讲,只存在两种不同的加载器:一种是启动类加载器(用C++实现),另一种就是所有其他的类加载器。使用java实现。从开发人员来讲又分为:启动类加载器,扩展类加载器,应用程序类加载器。类加载器之间的层级关系称为双亲委派模型。双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类去完成。因此所有加载器最终都应当传送到顶层的启动类加载器,只有顶层加载器无法加载时,子加载器才会尝试去加载。双亲委派模型对于保证java程序的运行非常重要,但它的实现却非常简单,实现双亲委派的代码都集中在java.lang.ClassLoader中的loadClass()中:先检查是否被加载过,若没有加载则调用父加载器的loadClass()方法,若父加载器为空默认使用启动类加载器,如果父类加载失败,则调用自己的findClass()方法进行加载。
OSGI已经成为业界事实上的java模块化标准,而osgi实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块都有一个自己的类加载器,当需要更换一个模块时,就把模块连同类加载器一起换掉以实现代码的热替换。OSGI对类加载器的使用是很值得学习的。
运行时栈桢结构:栈桢是用于支持虚拟机进行方法调用和方法执行的数据结构,存储了方法的局部变量表,操作数栈,动态连接和方法返回地址等信息。每个方法从调用开始到执行完成,都对应着一个栈桢在虚拟机栈里面从入栈到出栈的过程。
一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态,只有位于栈顶的栈桢才是有效的。
程序编译与代码优化::::
泛型与类型擦除:泛型的本质是参数化类型,也就是说所操作的数据类型被指定为一个参数。在泛型出现之前,只能通过Object是所有类型的父类和类型强制转换两个特点的配合来实现类型泛化。java语言的泛型它只在源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型了,并且在相应的地方插入了强制转型代码。因此,对于运行期的java语言来说,ArrayList,ArrayList就是同一个类,以下是简单的java泛型的例子:
1. Map map = new HashMap();
2. map.put("how are you", "你好");
3. map.put("hello", "你好");
4. System.out.println(map.get("hello"));
把这段代码编译成Class文件,然后再用字节码反编译工具进行反编译后,将会发现泛型都不见了。
1. Map map = new HashMap();
2. map.put("hello", "你好");
3. map.put("how are you", "吃了没");
4. System.out.println((String)map.get("hello"));
通过类型探险来实现泛型在某些情况下存在一些不足,比如当泛型遇见重载如下代码:
1. public class Test {
2. public static void method(List list){
3. }
4. public static void method(List list){
5. }
6. }
上述这段代码是不能编译运行的,因为它们的参数泛型擦除后变成相同的原生类型导致无法重载。但如何这两个方法添加了不同的返回值,便可以实现重载。但只限于jdk1.6。
自动装箱、拆箱、与遍历循环:::
1. public class Test {
2. public static void main(String[] args) {
3. List list = Arrays.asList(1,2,3,4);//变长参数,自动装箱
4. int sum = 0;
5. for (int i : list) {//自动拆箱
6. sum += i;
7. }
8. System.out.println(sum);
9. }
10. }
自动装箱与自动拆箱也会有一些问题:鉴于包装类的“==”运行在不遇到算术运算的情况下不会自动拆箱,以及它们的equals()方法不处理数据转型的问题。
业界有很多针对程序写的好不好的辅助校验工具,如“findbug,checkstyle”等。
findbugs直接在eclipse中从网址下载:
http://findbugs.cs.umd.edu/eclipse
高效并发::::::::::::::::::::
衡量一个服务性能的好坏,每秒事务处理数(tps)是最重要的指标之一,它代表着一秒内服务端平均能响应的请求总数,而tps与程序的并发能力又有非常密切的关系,幸好java语 言和虚拟机都提供了很多工具,把并发编程的门槛降低了不少。并且各种中间件服务器,各种框架都努力替程序员处理尽可能多的线程并发细节。
java虚拟机规范中试图定义一种java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这些细节。此时的变量包括:实例字段,静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然不会存在竞争问题(此处注意,如果局部变量是一个引用类型,它引用的对象在堆中可被各个线程共享,但引用本身在线程的栈中的局部变量中,它是私有的)。java内存模型规定了所有的变量都存储在主内存,每条线程还有自己的工作内存。线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程也无法直接访问对方工作内存中的变量。线程间变量值的传递均需要通过主内存来完成。关于拷贝副本,不会有虚拟机把整个对象拷贝一份的,这个对象的引用,对象中某个在线程访问到的字段是有可能存在拷贝的。根据虚拟机规范:volatile变量依然有工作内存的拷贝,只是它特殊的操作顺序性规定,看起来如同直接在主内存中读写访问一般。::主内存主要对应于堆中的对象实例数据部分,而工作内存则对应虚拟机栈中的部分区域。
关于主内存与工作内存之间的交互协议,定义了8种操作来完成,虚拟机必须保证这8种操作都是原子性的:
lock:锁定,作用于主内存中的变量,它把一个变量标识为一条线程独占的状态。
unlock:解锁,把一个处于锁定状态的变量解锁出来,释放后的变量才可以被其他线程锁定。
read:读取,将主内存中的变量读到工作内存。
load:载入,把read操作从主内存读到的值放在工作内存中的变量副本中。
use:使用
assign:赋值,作用于工作内存中的变量,将值赋给工作内存中的变量。
store:存储,将工作内存中的变量的值传送到主内存中。
write:写入,将传到主内存中的变量值写入到主内存中的变量是。
如果要把一个变量从主内存复制到工作内存,那么就顺序的执行read,load。如果从工作内存复制到主内存,就顺序的执行store,write。内存模型只要求上述两个操作是顺序的,但并不要求是连续的。也就是说,它们之间可以插入其它指令。这8种操作必须满足如下规则:
不允许read,load或store,write操作之一单独出现,即不允许一个变量从主内存复制到工作内存,但工作内存不接受,或者从工作内存写入主内存,主内存不接受。
不允许一个线程丢弃它最近的assign操作。即该变量在工作内存中发生变化后必须同步给主内存。
不允许一个线程无原因的把数据从工作线程同步回主内存,没有发生任何assign操作。
一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个没有被初始化的变量。
一个线程同一时刻只允许被一条线程lock,但可以被同一个线程多次lock,多次lock后需要调用相同次数unlock。
如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在使用这个变量前,需要重新load.对一个变量执行unlock前,必须先写回主内存。
volatile的特殊规则:最轻量级的同步机制,当一个 变量定义为volatile后,它将具备两种特性,第一是保证它对所有线程的可见性,这里的可见性是指,当一个线程修改了这个值,新值对于其它线程来说是立即可知的。对volatile变量的写操作能立刻反应到其它线程当中,但是java里面的运算并非原子操作。导致volatile变量的运算在并发下并不是安全的。如下代码所示:
1. public class Test {
2. public static volatile int race = 0;
3. public static void main(String[] args) {
4. Thread[] threads = new Thread[20];
5. for (int i = 0; i < threads.length; i++) {
6. threads[i] = new Thread(new Runnable() {
7. @Override
8. public void run() {
9. // TODO Auto-generated method stub
10. for (int j = 0; j < 10000; j++) {
11. increase();
12. }
13. }
14. });
15. threads[i].start();
16. }
17. //等待所有线程结束
18. while(Thread.activeCount() > 1){
19. Thread.yield();
20. }
21. System.out.println(race);
22. }
23. public static void increase(){
24. race++;
25. }
26. }
这段代码起了20个线程,如果能够正确并发的话,结果应是200000,但每次运行都小于这个结果。由于volatile只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁来实现(使用synchronized或java.util.concurrent)来保证原子性。像如下的代码就很适合使用volatile变量控制并发。当shutdown()方法被调用时,能保证所有方法中执行的doWork()方法都立即停下来。
volatile boolean shutdownRequest;
public void shutdown(){
shutdownRequest = true;
}
public void doWork(){
while(!shutdownRequest){}
}
使用volatile的第二个语义是,禁止指令重排序优化,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。如果定义initialized变量时没有使用 volatile修饰,就可能会由于指令重排序的优化,导致位于线程A的最后一句initialized=true提前执行,这样在线程B中使用配置信息的代码时就会出错。而volatile不会发生这样的错误。大多数场景下volatile的总开销仍然要比锁低。我们在volatile与锁之间选择的唯一依据仅仅是volatile的语义能否满足使用场景的需要。回头看一下定义的特殊规则:在工作内存中,每次使用变量前都必须从主内存中刷新最新的值,用于保证能看见其它线程对变量所做的修改后的值。在工作内存中,每次修改变量后都必须立即同步给主内存,变量不会被重排序优化,代码的执行顺序与程序的顺序相同。
原子性,可见性,有序性::大致可以认为基本数据类型的访问读写是具备原子性的。synchronized()块之间的代码也具备原子性。除了 volatile ,还有两个关键字可以实现可见性,一个是synchronized,一个是final,synchronized在对一个变量执行unlock操作时,必须把变量写回主内存。而final关键字的可见性是指:被final修饰的字段一旦在构造器中初始化完成,并且构造器没有把this引用 传递出去,那么其它线程中就能看到final的值。java 语言提供了synchronized,volatile来保证线程之间操作的有序性。
先行发生原则:无须任何同步器就已经存在,可以在编码中直接使用。
程序次序规则:同一个线程内,按照程序代码顺序,书写在前面的操作先于发生书写在后面的操作。
volatile变量规则:对于个volatile变量的写操作优先于后面对这个变量的读操作。
一个操作时间上的先发生,不代表这个操作会是先行发生。时间先后顺序与先行发生原则之间没有关系,衡量并发安全的问题时不要受到时间顺序的影响,一切必须以先行发生原则为准。
Thread类的很多关键方法都声明为native,可能是为了执行效率而采用平台相关的方法。
java使用的线程调度方式就是采用抢占式调度,java的线程优先级并不是太靠谱,原因是java的线程是通过映射到系统的原生线程上实现的。
我们这里讨论的线程安全,就存在于多个线程之间存在共享数据访问这个前提,因为如果一段代码根本不会与其它线程共享数据,多线程对它是没有影响的。我们可以将各种操作共享的数据分为以下五类:不可变,绝对线程安全,相对线程安全,线程兼容和线程对立。
不可变的对象一定是线程安全的。只要一个不可变的对象被正确的构建出来,那其外部的可见状态状态永远也不会改变,它带来的安全是最简单的。在java语言中,如果共享数据是一个基本数据类型,只要在定义时使用final就可以保证它是不可变的。如果数据是一个对象,就需要保证对象的行为不会对其状态产生任何影响才行。如果没明白,想想String类的subString,replace,concat方法都不会影响它原来的值,只会返回一个新构造的字符串对象。保证对象行为不影响自己状态的方法有很多种,最简单的就是把对象中带有状态的变量全部声明为final,这样在构造函数结束之后,它就是不可变的。如java.lang.Integer构造函数中将内部状态变量设置为final。
绝对纯种安全,如果说java.util.Vector是一个线程安全的容器,因为它所有的方法add,get,size都是被synchronized修饰的,尽管这样效率很低,但确实是安全的。即使它所有的方法都被修饰成同步,也不意味着调用它的时候永远都不需要同步手段了。如果不在方法调用端作额外的同步措施的话,它仍然是不安全的。因为如果一个线程在错误的时间内删除了一个元素,导致序号i不可用的时候,另一个线程再用i访问数组就会出错。我们必须写成如下代码才表明是安全的:
1. public class Test {
2. private static Vector vector = new Vector<>();
3. public static void main(String[] args) {
4. while (true) {
5. for (int i = 0; i < 10; i++) {
6. vector.add(i);
7. }
8. Thread remoThread = new Thread(new Runnable() {
9. @Override
10. public void run(){
11. synchronized (vector) {
12. for (int i = 0; i < vector.size(); i++) {
13. vector.remove(i);
14. }
15. }
16. }
17. });
18. Thread printThread = new Thread(new Runnable() {
19. @Override
20. public void run(){
21. synchronized (vector) {
22. for (int i = 0; i < vector.size(); i++) {
23. System.out.println(vector.get(i));;
24. }
25. }
26. }
27. });
28. remoThread.start();
29. printThread.start();
30. while(Thread.activeCount() > 20){
31. //不要同时产生过多的线程,否则会导致系统假死。
32. }
33. }
34. }
35. }
相对线程安全:它需要保证对这个对象的单独操作是线程安全的,我们调用的时候不需要额外措施,对一些特定顺序的连续调用,就需要在调用端使用额外的同步手段保证调用的正确性。大部分线程安全类是这种类型,如Vector,hashtable.
线程兼容:对象本身并不是线程安全的,但可以在调用端正确的使用同步手段来保证对象在并发环境中可以安全的使用。如arraylist,hashmap
线程安全的实现方法:::
互斥同步,保证共享数据在同一时刻只被一个线程使用。最基本的手段就是synchronized。如果明确指定的对象参数,那就是这个对象的refrence,否则,根据修饰的是实例方法还是类方法,去取相应的实例对象或类对象作为锁对象。synchronized 是一个重量级的操作,有经验的程序员都会在确实有必要的情况下采用这个操作。还可以使用java.util.concurrent包中的ReentrantLock来实现同步。相比synchronized,ReentrantLock增加了一些高级功能,如:等待可中断,可实现公平锁,以及锁可以梆定多个条件。
多线程环境下synchronized的性能下降很厉害,而ReentrantLock性能还可以,,能基本保持一个稳定水平。在jdk1.6后,两者性能相差不多,性能因素不再是选择reentrantLock的原因了。所以还是提倡优先使用synchronized实现同步。
非阻塞同步,先进行操作,如果没有其它线程争用共享数据,操作成功。否则产生冲突,采取其它补偿措施,最常见的就是不断重试,直到成功。这里我们需要保证操作和冲突检测具备原子性,不能使用互斥同步,使用什么呢?靠硬件完成,通过一条处理器指令就能完成,如:测试并设置,获取并增加,交换,比较并交换,加载链接/条件存储。如下的automic原子自增运算::
1. public class Test {
2. public static AtomicInteger race = new AtomicInteger(0);
3. public static void main(String[] args) {
4. Thread[] threads = new Thread[20];
5. for (int i = 0; i < threads.length; i++) {
6. threads[i] = new Thread(new Runnable() {
7. @Override
8. public void run() {
9. // TODO Auto-generated method stub
10. for (int j = 0; j < 10000; j++) {
11. increase();
12. }
13. }
14. });
15. threads[i].start();
16. }
17. //等待所有线程结束
18. while(Thread.activeCount()>1){
19. Thread.yield();
20. }
21. System.out.println(race);
22. }
23. public static void increase(){
24. race.incrementAndGet();
25. }
26. }
1. public final int incrementAndGet() {
2. for (;;) {
3. int current = get();
4. int next = current + 1;
5. if (compareAndSet(current, next))
6. return next;
7. }
8. }
incrementAndGet方法很简单,它在一个无限循环中,不断执行“获取-设置”,直到成功。尽管CAS看起来很美,但它无法涵盖互斥同步的所有使用场景。
无同步方案:如果一个方法本来就不涉及共享数据,那么它自然就无须任何措施去保证正确性。因此会有一些代码天生是安全的。比如可重入代码,有一些特征,如不依赖于堆上的数据和公用的系统资源,用到的状态都是由参数中传入。如果一个方法它的结果是可以预测的,即输入相同的参数,就能返回相同的结果,也就是线程安全的。第二是,线程本地存储,如果一段代码中所需要的数据必须保证与其它代码共享,那就看看这些共享数据的代码能否保证在同一个线程内执行?如果能保证,就可以把共享数据的范围限制在同一个线程内,这样,无须同步也能保证数据共享一致。符合这种特点的场景并不多,大部分使用消费队列架构模式,都会将产品的消费过程尽量在一个线程内消耗完。其中最重要的一个实例就是经典WEB 交互模式中的“一个请求对应一个服务器线程”的处理方式,这种处理方式的广泛使得WEB服务端应用都可以使用线程本地存储来解决线程安全问题。如果一个变量要被多线程访问,使用volatile关键字来声明它是易变的,如果一个变量要被某个线程独享,可以通过ThreadLocal类来实现本地存储的功能。