Inside JVM 内存模型
来源 原文:https://blog.csdn.net/silentbalanceyh/article/details/4661230
参考:IBM开发中心文档,《Inside JVM》
本文章节:
1.JMM简介
2.堆和栈
3.本机内存
4.防止内存泄漏
1.JMM简介
i.内存模型概述
Java平台自动集成了线程以及多处理器技术,这种集成程度比Java以前诞生的计算机语言要厉害很多,该语言针对多种异构平台的平*立性而使用的多线程技术支持也是具有开拓性的一面,有时候在开发Java同步和线程安全要求很严格的程序时,往往容易混淆的一个概念就是内存模型。究竟什么是内存模型?内存模型描述了程序中各个变量(实例域、静态域和数组元素)之间的关系,以及在实际计算机系统中将变量存储到内存和从内存中取出变量这样的底层细节,对象最终是存储在内存里面的,这点没有错,但是编译器、运行库、处理器或者系统缓存可以有特权在变量指定内存位置存储或者取出变量的值。【JMM】(Java Memory Model的缩写)允许编译器和缓存以数据在处理器特定的缓存(或寄存器)和主存之间移动的次序拥有重要的特权,除非程序员使用了final或synchronized明确请求了某些可见性的保证。
1)JSR133:
在Java语言规范里面指出了JMM是一个比较开拓性的尝试,这种尝试视图定义一个一致的、跨平台的内存模型,但是它有一些比较细微而且很重要的缺点。其实Java语言里面比较容易混淆的关键字主要是synchronized和volatile,也因为这样在开发过程中往往开发者会忽略掉这些规则,这也使得编写同步代码比较困难。
JSR133本身的目的是为了修复原本JMM的一些缺陷而提出的,其本身的制定目标有以下几个:
- 保留目前JVM的安全保证,以进行类型的安全检查:
- 提供(out-of-thin-air safety)无中生有安全性,这样“正确同步的”应该被正式而且直观地定义
- 程序员要有信心开发多线程程序,当然没有其他办法使得并发程序变得很容易开发,但是该规范的发布主要目标是为了减轻程序员理解内存模型中的一些细节负担
- 提供大范围的流行硬件体系结构上的高性能JVM实现,现在的处理器在它们的内存模型上有着很大的不同,JMM应该能够适合于实际的尽可能多的体系结构而不以性能为代价,这也是Java跨平台型设计的基础
- 提供一个同步的习惯用法,以允许发布一个对象使他不用同步就可见,这种情况又称为初始化安全(initialization safety)的新的安全保证
- 对现有代码应该只有最小限度的影响
2)同步、异步【这里仅仅指概念上的理解,不牵涉到计算机底层基础的一些操作】:
在系统开发过程,经常会遇到这几个基本概念,不论是网络通讯、对象之间的消息通讯还是Web开发人员常用的Http请求都会遇到这样几个概念,经常有人提到Ajax是异步通讯方式,那么究竟怎样的方式是这样的概念描述呢?
同步:同步就是在发出一个功能调用的时候,在没有得到响应之前,该调用就不返回,按照这样的定义,其实大部分程序的执行都是同步调用的,一般情况下,在描述同步和异步操作的时候,主要是指代需要其他部件协作处理或者需要协作响应的一些任务处理。比如有一个线程A,在A执行的过程中,可能需要B提供一些相关的执行数据,当然触发B响应的就是A向B发送一个请求或者说对B进行一个调用操作,如果A在执行该操作的时候是同步的方式,那么A就会停留在这个位置等待B给一个响应消息,在B没有任何响应消息回来的时候,A不能做其他事情,只能等待,那么这样的情况,A的操作就是一个同步的简单说明。
异步:异步就是在发出一个功能调用的时候,不需要等待响应,继续进行它该做的事情,一旦得到响应了过后给予一定的处理,但是不影响正常的处理过程的一种方式。比如有一个线程A,在A执行的过程中,同样需要B提供一些相关数据或者操作,当A向B发送一个请求或者对B进行调用操作过后,A不需要继续等待,而是执行A自己应该做的事情,一旦B有了响应过后会通知A,A接受到该异步请求的响应的时候会进行相关的处理,这种情况下A的操作就是一个简单的异步操作。
3)可见性、可排序性
Java内存模型的两个关键概念:可见性(Visibility)和可排序性(Ordering)
开发过多线程程序的程序员都明白,synchronized关键字强制实施一个线程之间的互斥锁(相互排斥),该互斥锁防止每次有多个线程进入一个给定监控器所保护的同步语句块,也就是说在该情况下,执行程序代码所独有的某些内存是独占模式,其他的线程是不能针对它执行过程所独占的内存进行访问的,这种情况称为该内存不可见。但是在该模型的同步模式中,还有另外一个方面:JMM中指出了,JVM在处理该强制实施的时候可以提供一些内存的可见规则,在该规则里面,它确保当存在一个同步块时,缓存被更新,当输入一个同步块时,缓存失效。因此在JVM内部提供给定监控器保护的同步块之中,一个线程所写入的值对于其余所有的执行由同一个监控器保护的同步块线程来说是可见的,这就是一个简单的可见性的描述。这种机器保证编译器不会把指令从一个同步块的内部移到外部,虽然有时候它会把指令由外部移动到内部。JMM在缺省情况下不做这样的保证——只要有多个线程访问相同变量时必须使用同步。简单总结:
可见性就是在多核或者多线程运行过程中内存的一种共享模式,在JMM模型里面,通过并发线程修改变量值的时候,必须将线程变量同步回主存过后,其他线程才可能访问到。
【*:简单讲,内存的可见性使内存资源可以共享,当一个线程执行的时候它所占有的内存,如果它占有的内存资源是可见的,那么这时候其他线程在一定规则内是可以访问该内存资源的,这种规则是由JMM内部定义的,这种情况下内存的该特性称为其可见性。】
可排序性提供了内存内部的访问顺序,在不同的程序针对不同的内存块进行访问的时候,其访问不是无序的,比如有一个内存块,A和B需要访问的时候,JMM会提供一定的内存分配策略有序地分配它们使用的内存,而在内存的调用过程也会变得有序地进行,内存的折中性质可以简单理解为有序性。而在Java多线程程序里面,JMM通过Java关键字volatile来保证内存的有序访问。
ii.JMM结构:
1)简单分析:
Java语言规范中提到过,JVM中存在一个主存区(Main Memory或Java Heap Memory),Java中所有变量都是存在主存中的,对于所有线程进行共享,而每个线程又存在自己的工作内存(Working Memory),工作内存中保存的是主存中某些变量的拷贝,线程对所有变量的操作并非发生在主存区,而是发生在工作内存中,而线程之间是不能直接相互访问,变量在程序中的传递,是依赖主存来完成的。而在多核处理器下,大部分数据存储在高速缓存中,如果高速缓存不经过内存的时候,也是不可见的一种表现。在Java程序中,内存本身是比较昂贵的资源,其实不仅仅针对Java应用程序,对操作系统本身而言内存也属于昂贵资源,Java程序在性能开销过程中有几个比较典型的可控制的来源。synchronized和volatile关键字提供的内存中模型的可见性保证程序使用一个特殊的、存储关卡(memory barrier)的指令,来刷新缓存,使缓存无效,刷新硬件的写缓存并且延迟执行的传递过程,无疑该机制会对Java程序的性能产生一定的影响。
JMM的最初目的,就是为了能够支持多线程程序设计的,每个线程可以认为是和其他线程不同的CPU上运行,或者对于多处理器的机器而言,该模型需要实现的就是使得每一个线程就像运行在不同的机器、不同的CPU或者本身就不同的线程上一样,这种情况实际上在项目开发中是常见的。对于CPU本身而言,不能直接访问其他CPU的寄存器,模型必须通过某种定义规则来使得线程和线程在工作内存中进行相互调用而实现CPU本身对其他CPU、或者说线程对其他线程的内存中资源的访问,而表现这种规则的运行环境一般为运行该程序的运行宿主环境(操作系统、服务器、分布式系统等),而程序本身表现就依赖于编写该程序的语言特性,这里也就是说用Java编写的应用程序在内存管理中的实现就是遵循其部分原则,也就是前边提及到的JMM定义了Java语言针对内存的一些的相关规则。然而,虽然设计之初是为了能够更好支持多线程,但是该模型的应用和实现当然不局限于多处理器,而在JVM编译器编译Java编写的程序的时候以及运行期执行该程序的时候,对于单CPU的系统而言,这种规则也是有效的,这就是是上边提到的线程和线程之间的内存策略。JMM本身在描述过程没有提过具体的内存地址以及在实现该策略中的实现方法是由JVM的哪一个环节(编译器、处理器、缓存控制器、其他)提供的机制来实现的,甚至针对一个开发非常熟悉的程序员,也不一定能够了解它内部对于类、对象、方法以及相关内容的一些具体可见的物理结构。相反,JMM定义了一个线程与主存之间的抽象关系,其实从上边的图可以知道,每一个线程可以抽象成为一个工作内存(抽象的高速缓存和寄存器),其中存储了Java的一些值,该模型保证了Java里面的属性、方法、字段存在一定的数学特性,按照该特性,该模型存储了对应的一些内容,并且针对这些内容进行了一定的序列化以及存储排序操作,这样使得Java对象在工作内存里面被JVM顺利调用,(当然这是比较抽象的一种解释)既然如此,大多数JMM的规则在实现的时候,必须使得主存和工作内存之间的通信能够得以保证,而且不能违反内存模型本身的结构,这是语言在设计之处必须考虑到的针对内存的一种设计方法。这里需要知道的一点是,这一切的操作在Java语言里面都是依靠Java语言自身来操作的,因为Java针对开发人员而言,内存的管理在不需要手动操作的情况下本身存在内存的管理策略,这也是Java自己进行内存管理的一种优势。
[1]原子性(Atomicity):
这一点说明了该模型定义的规则针对原子级别的内容存在独立的影响,对于模型设计最初,这些规则需要说明的仅仅是最简单的读取和存储单元写入的的一些操作,这种原子级别的包括——实例、静态变量、数组元素,只是在该规则中不包括方法中的局部变量。
[2]可见性(Visibility):
在该规则的约束下,定义了一个线程在哪种情况下可以访问另外一个线程或者影响另外一个线程,从JVM的操作上讲包括了从另外一个线程的可见区域读取相关数据以及将数据写入到另外一个线程内。
[3]可排序性(Ordering):
该规则将会约束任何一个违背了规则调用的线程在操作过程中的一些顺序,排序问题主要围绕了读取、写入和赋值语句有关的序列。
如果在该模型内部使用了一致的同步性的时候,这些属性中的每一个属性都遵循比较简单的原则:和所有同步的内存块一样,每个同步块之内的任何变化都具备了原子性以及可见性,和其他同步方法以及同步块遵循同样一致的原则,而且在这样的一个模型内,每个同步块不能使用同一个锁,在整个程序的调用过程是按照编写的程序指定指令运行的。即使某一个同步块内的处理可能会失效,但是该问题不会影响到其他线程的同步问题,也不会引起连环失效。简单讲:当程序运行的时候使用了一致的同步性的时候,每个同步块有一个独立的空间以及独立的同步控制器和锁机制,然后对外按照JVM的执行指令进行数据的读写操作。这种情况使得使用内存的过程变得非常严谨!
如果不使用同步或者说使用同步不一致(这里可以理解为异步,但不一定是异步操作),该程序执行的答案就会变得极其复杂。而且在这样的情况下,该内存模型处理的结果比起大多数程序员所期望的结果而言就变得十分脆弱,甚至比起JVM提供的实现都脆弱很多。因为这样所以出现了Java针对该内存操作的最简单的语言规范来进行一定的习惯限制,排除该情况发生的做法在于:
JVM线程必须依靠自身来维持对象的可见性以及对象自身应该提供相对应的操作而实现整个内存操作的三个特性,而不是仅仅依靠特定的修改对象状态的线程来完成如此复杂的一个流程。
【*:综上所属,JMM在JVM内部实现的结构就变得相对复杂,当然一般的Java初学者可以不用了解得这么深入。】
[4]三个特性的解析(针对JMM内部):
原子性(Atomicity):
访问存储单元内的任何类型的字段的值以及对其更新操作的时候,除开long类型和double类型,其他类型的字段是必须要保证其原子性的,这些字段也包括为对象服务的引用。此外,该原子性规则扩展可以延伸到基于long和double的另外两种类型:volatile long和volatile double(volatile为java关键字),没有被volatile声明的long类型以及double类型的字段值虽然不保证其JMM中的原子性,但是是被允许的。针对non-long/non-double的字段在表达式中使用的时候,JMM的原子性有这样一种规则:如果你获得或者初始化该值或某一些值的时候,这些值是由其他线程写入,而且不是从两个或者多个线程产生的数据在同一时间戳混合写入的时候,该字段的原子性在JVM内部是必须得到保证的。也就是说JMM在定义JVM原子性的时候,只要在该规则不违反的条件下,JVM本身不去理睬该数据的值是来自于什么线程,因为这样使得Java语言在并行运算的设计的过程中针对多线程的原子性设计变得极其简单,而且即使开发人员没有考虑到最终的程序也没有太大的影响。再次解释一下:这里的原子性指的是原子级别的操作,比如最小的一块内存的读写操作,可以理解为Java语言最终编译过后最接近内存的最底层的操作单元,这种读写操作的数据单元不是变量的值,而是本机码,也就是前边在讲《Java基础知识》中提到的由运行器解释的时候生成的Native Code。
可见性(Visibility):
当一个线程需要修改另外线程的可见单元的时候必须遵循以下原则:
- 一个写入线程释放的同步锁和紧随其后进行读取的读线程的同步锁是同一个
从本质上讲,释放锁操作强迫它的隶属线程【释放锁的线程】从工作内存中的写入缓存里面刷新(专业上讲这里不应该是刷新,可以理解为提供)数据(flush操作),然后获取锁操作使得另外一个线程【获得锁的线程】直接读取前一个线程可访问域(也就是可见区域)的字段的值。因为该锁内部提供了一个同步方法或者同步块,该同步内容具有线程排他性,这样就使得上边两个操作只能针对单一线程在同步内容内部进行操作,这样就使得所有操作该内容的单一线程具有该同步内容(加锁的同步方法或者同步块)内的线程排他性,这种情况的交替也可以理解为具有“短暂记忆效应”。
这里需要理解的是同步的双重含义:使用锁机制允许基于高层同步协议进行处理操作,这是最基本的同步;同时系统内存(很多时候这里是指基于机器指令的底层存储关卡memory barrier,前边提到过)在处理同步的时候能够跨线程操作,使得线程和线程之间的数据是同步的。这样的机制也折射出一点,并行编程相对于顺序编程而言,更加类似于分布式编程。后一种同步可以作为JMM机制中的方法在一个线程中运行的效果展示,注意这里不是多个线程运行的效果展示,因为它反应了该线程愿意发送或者接受的双重操作,并且使得它自己的可见区域可以提供给其他线程运行或者更新,从这个角度来看,使用锁和消息传递可以视为相互之间的变量同步,因为相对其他线程而言,它的操作针对其他线程也是对等的。
- 一旦某个字段被申明为volatile,在任何一个写入线程在工作内存中刷新缓存的之前需要进行进一步的内存操作,也就是说针对这样的字段进行立即刷新,可以理解为这种volatile不会出现一般变量的缓存操作,而读取线程每次必须根据前一个线程的可见域里面重新读取该变量的值,而不是直接读取。
- 当某个线程第一次去访问某个对象的域的时候,它要么初始化该对象的值,要么从其他写入线程可见域里面去读取该对象的值;这里结合上边理解,在满足某种条件下,该线程对某对象域的值的读取是直接读取,有些时候却需要重新读取。
这里需要小心一点的是,在并发编程里面,不好的一个实践就是使用一个合法引用去引用不完全构造的对象,这种情况在从其他写入线程可见域里面进行数据读取的时候发生频率比较高。从编程角度上讲,在构造函数里面开启一个新的线程是有一定的风险的,特别是该类是属于一个可子类化的类的时候。Thread.start由调用线程启动,然后由获得该启动的线程释放锁具有相同的“短暂记忆效应”,如果一个实现了Runnable接口的超类在子类构造子执行之前调用了Thread(this).start()方法,那么就可能使得该对象在线程方法run执行之前并没有被完全初始化,这样就使得一个指向该对象的合法引用去引用了不完全构造的一个对象。同样的,如果创建一个新的线程T并且启动该线程,然后再使用线程T来创建对象X,这种情况就不能保证X对象里面所有的属性针对线程T都是可见的除非是在所有针对X对象的引用中进行同步处理,或者最好的方法是在T线程启动之前创建对象X。
- 若一个线程终止,所有的变量值都必须从工作内存中刷到主存,比如,如果一个同步线程因为另一个使用Thread.join方法的线程而终止,那么该线程的可见域针对那个线程而言其发生的改变以及产生的一些影响是需要保证可知道的。
注意:如果在同一个线程里面通过方法调用去传一个对象的引用是绝对不会出现上边提及到的可见性问题的。JMM保证所有上边的规定以及关于内存可见性特性的描述——一个特殊的更新、一个特定字段的修改都是某个线程针对其他线程的一个“可见性”的概念,最终它发生的场所在内存模型中Java线程和线程之间,至于这个发生时间可以是一个任意长的时间,但是最终会发生,也就是说,Java内存模型中的可见性的特性主要是针对线程和线程之间使用内存的一种规则和约定,该约定由JMM定义。
不仅仅如此,该模型还允许不同步的情况下可见性特性。比如针对一个线程提供一个对象或者字段访问域的原始值进行操作,而针对另外一个线程提供一个对象或者字段刷新过后的值进行操作。同样也有可能针对一个线程读取一个原始的值以及引用对象的对象内容,针对另外一个线程读取一个刷新过后的值或者刷新过后的引用。
尽管如此,上边的可见性特性分析的一些特征在跨线程操作的时候是有可能失败的,而且不能够避免这些故障发生。这是一个不争的事实,使用同步多线程的代码并不能绝对保证线程安全的行为,只是允许某种规则对其操作进行一定的限制,但是在最新的JVM实现以及最新的Java平台中,即使是多个处理器,通过一些工具进行可见性的测试发现其实是很少发生故障的。跨线程共享CPU的共享缓存的使用,其缺陷就在于影响了编译器的优化操作,这也体现了强有力的缓存一致性使得硬件的价值有所提升,因为它们之间的关系在线程与线程之间的复杂度变得更高。这种方式使得可见度的*测试显得更加不切实际,因为这些错误的发生极为罕见,或者说在平台上我们开发过程中根本碰不到。在并行程开发中,不使用同步导致失败的原因也不仅仅是对可见度的不良把握导致的,导致其程序失败的原因是多方面的,包括缓存一致性、内存一致性问题等。
可排序性(Ordering):
可排序规则在线程与线程之间主要有下边两点:
- 从操作线程的角度看来,如果所有的指令执行都是按照普通顺序进行,那么对于一个顺序运行的程序而言,可排序性也是顺序的
- 从其他操作线程的角度看来,排序性如同在这个线程中运行在非同步方法中的一个“间谍”,所以任何事情都有可能发生。唯一有用的限制是同步方法和同步块的相对排序,就像操作volatile字段一样,总是保留下来使用
【*:如何理解这里“间谍”的意思,可以这样理解,排序规则在本线程里面遵循了第一条法则,但是对其他线程而言,某个线程自身的排序特性可能使得它不定地访问执行线程的可见域,而使得该线程对本身在执行的线程产生一定的影响。举个例子,A线程需要做三件事情分别是A1、A2、A3,而B是另外一个线程具有操作B1、B2,如果把参考定位到B线程,那么对A线程而言,B的操作B1、B2有可能随时会访问到A的可见区域,比如A有一个可见区域a,A1就是把a修改称为1,但是B线程在A线程调用了A1过后,却访问了a并且使用B1或者B2操作使得a发生了改变,变成了2,那么当A按照排序性进行A2操作读取到a的值的时候,读取到的是2而不是1,这样就使得程序最初设计的时候A线程的初衷发生了改变,就是排序被打乱了,那么B线程对A线程而言,其身份就是“间谍”,而且需要注意到一点,B线程的这些操作不会和A之间存在等待关系,那么B线程的这些操作就是异步操作,所以针对执行线程A而言,B的身份就是“非同步方法中的‘间谍’。】
同样的,这仅仅是一个最低限度的保障性质,在任何给定的程序或者平台,开发中有可能发现更加严格的排序,但是开发人员在设计程序的时候不能依赖这种排序,如果依赖它们会发现测试难度会成指数级递增,而且在复合规定的时候会因为不同的特性使得JVM的实现因为不符合设计初衷而失败。
注意:第一点在JLS(Java Language Specification)的所有讨论中也是被采用的,例如算数表达式一般情况都是从上到下、从左到右的顺序,但是这一点需要理解的是,从其他操作线程的角度看来这一点又具有不确定性,对线程内部而言,其内存模型本身是存在排序性的。【*:这里讨论的排序是最底层的内存里面执行的时候的NativeCode的排序,不是说按照顺序执行的Java代码具有的有序性质,本文主要分析的是JVM的内存模型,所以希望读者明白这里指代的讨论单元是内存区。】
iii.原始JMM缺陷:
JMM最初设计的时候存在一定的缺陷,这种缺陷虽然现有的JVM平台已经修复,但是这里不得不提及,也是为了读者更加了解JMM的设计思路,这一个小节的概念可能会牵涉到很多更加深入的知识,如果读者不能读懂没有关系先看了文章后边的章节再返回来看也可以。
1)问题1:不可变对象不是不可变的
学过Java的朋友都应该知道Java中的不可变对象,这一点在本文最后讲解String类的时候也会提及,而JMM最初设计的时候,这个问题一直都存在,就是:不可变对象似乎可以改变它们的值(这种对象的不可变指通过使用final关键字来得到保证),(Publis Service Reminder:让一个对象的所有字段都为final并不一定使得这个对象不可变——所有类型还必须是原始类型而不能是对象的引用。而不可变对象被认为不要求同步的。但是,因为在将内存写方面的更改从一个线程传播到另外一个线程的时候存在潜在的延迟,这样就使得有可能存在一种竞态条件,即允许一个线程首先看到不可变对象的一个值,一段时间之后看到的是一个不同的值。这种情况以前怎么发生的呢?在JDK 1.4中的String实现里,这儿基本有三个重要的决定性字段:对字符数组的引用、长度和描述字符串的开始数组的偏移量。String就是以这样的方式在JDK 1.4中实现的,而不是只有字符数组,因此字符数组可以在多个String和StringBuffer对象之间共享,而不需要在每次创建一个String的时候都拷贝到一个新的字符数组里。假设有下边的代码:
String s1 = "/usr/tmp";
String s2 = s1.substring(4); // "/tmp"
这种情况下,字符串s2将具有大小为4的长度和偏移量,但是它将和s1共享“/usr/tmp”里面的同一字符数组,在String构造函数运行之前,Object的构造函数将用它们默认的值初始化所有的字段,包括决定性的长度和偏移字段。当String构造函数运行的时候,字符串长度和偏移量被设置成所需要的值。但是在旧的内存模型中,因为缺乏同步,有可能另一个线程会临时地看到偏移量字段具有初始默认值0,而后又看到正确的值4,结果是s2的值从“/usr”变成了“/tmp”,这并不是我们真正的初衷,这个问题就是原始JMM的第一个缺陷所在,因为在原始JMM模型里面这是合理而且合法的,JDK 1.4以下的版本都允许这样做。
2)问题2:重新排序的易失性和非易失性存储
另一个主要领域是与volatile字段的内存操作重新排序有关,这个领域中现有的JMM引起了一些比较混乱的结果。现有的JMM表明易失性的读和写是直接和主存打交道的,这样避免了把值存储到寄存器或者绕过处理器特定的缓存,这使得多个线程一般能看见一个给定变量最新的值。可是,结果是这种volatile定义并没有最初想象中那样如愿以偿,并且导致了volatile的重大混乱。为了在缺乏同步的情况下提供较好的性能,编译器、运行时和缓存通常是允许进行内存的重新排序操作的,只要当前执行的线程分辨不出它们的区别。(这就是within-thread as-if-serial semantics[线程内似乎是串行]的解释)但是,易失性的读和写是完全跨线程安排的,编译器或缓存不能在彼此之间重新排序易失性的读和写。遗憾的是,通过参考普通变量的读写,JMM允许易失性的读和写被重排序,这样以为着开发人员不能使用易失性标志作为操作已经完成的标志。比如:
Map configOptions;
char[] configText;
volatile boolean initialized = false;
// 线程1
configOptions = new HashMap();
configText = readConfigFile(filename);
processConfigOptions(configText,configOptions);
initialized = true;
// 线程2
while(!initialized)
sleep();
这里的思想是使用易失性变量initialized担任守卫来表明一套别的操作已经完成了,这是一个很好的思想,但是不能在JMM下工作,因为旧的JMM允许非易失性的写(比如写到configOptions字段,以及写到由configOptions引用Map的字段中)与易失性的写一起重新排序,因此另外一个线程可能会看到initialized为true,但是对于configOptions字段或它所引用的对象还没有一个一致的或者说当前的针对内存的视图变量,volatile的旧语义只承诺在读和写的变量的可见性,而不承诺其他变量,虽然这种方法更加有效的实现,但是结果会和我们设计之初大相径庭。
2.堆和栈
i.Java内存管理简介:
内存管理在Java语言中是JVM自动操作的,当JVM发现某些对象不再需要的时候,就会对该对象占用的内存进行重分配(释放)操作,而且使得分配出来的内存能够提供给所需要的对象。在一些编程语言里面,内存管理是一个程序的职责,但是书写过C++的程序员很清楚,如果该程序需要自己来书写很有可能引起很严重的错误或者说不可预料的程序行为,最终大部分开发时间都花在了调试这种程序以及修复相关错误上。一般情况下在Java程序开发过程把手动内存管理称为显示内存管理,而显示内存管理经常发生的一个情况就是引用悬挂——也就是说有可能在重新分配过程释放掉了一个被某个对象引用正在使用的内存空间,释放掉该空间过后,该引用就处于悬挂状态。如果这个被悬挂引用指向的对象试图进行原来对象(因为这个时候该对象有可能已经不存在了)进行操作的时候,由于该对象本身的内存空间已经被手动释放掉了,这个结果是不可预知的。显示内存管理另外一个常见的情况是内存泄漏,当某些引用不再引用该内存对象的时候,而该对象原本占用的内存并没有被释放,这种情况简言为内存泄漏。比如,如果针对某个链表进行了内存分配,而因为手动分配不当,仅仅让引用指向了某个元素所处的内存空间,这样就使得其他链表中的元素不能再被引用而且使得这些元素所处的内存让应用程序处于不可达状态而且这些对象所占有的内存也不能够被再使用,这个时候就发生了内存泄漏。而这种情况一旦在程序中发生,就会一直消耗系统的可用内存直到可用内存耗尽,而针对计算机而言内存泄漏的严重程度大了会使得本来正常运行的程序直接因为内存不足而中断,并不是Java程序里面出现Exception那么轻量级。
在以前的编程过程中,手动内存管理带了计算机程序不可避免的错误,而且这种错误对计算机程序是毁灭性的,所以内存管理就成为了一个很重要的话题,但是针对大多数纯面向对象语言而言,比如Java,提供了语言本身具有的内存特性:自动化内存管理,这种语言提供了一个程序垃圾回收器(Garbage Collector[GC]),自动内存管理提供了一个抽象的接口以及更加可靠的代码使得内存能够在程序里面进行合理的分配。最常见的情况就是垃圾回收器避免了悬挂引用的问题,因为一旦这些对象没有被任何引用“可达”的时候,也就是这些对象在JVM的内存池里面成为了不可引用对象,该垃圾回收器会直接回收掉这些对象占用的内存,当然这些对象必须满足垃圾回收器回收的某些对象规则,而垃圾回收器在回收的时候会自动释放掉这些内存。不仅仅如此,垃圾回收器同样会解决内存泄漏问题。
ii.详解堆和栈[图片以及部分内容来自《Inside JVM》]:
1)通用简介
[编译原理]学过编译原理的人都明白,程序运行时有三种内存分配策略:静态的、栈式的、堆式的
静态存储——是指在编译时就能够确定每个数据目标在运行时的存储空间需求,因而在编译时就可以给它们分配固定的内存空间。这种分配策略要求程序代码中不允许有可变数据结构的存在,也不允许有嵌套或者递归的结构出现,因为它们都会导致编译程序无法计算准确的存储空间。
栈式存储——该分配可成为动态存储分配,是由一个类似于堆栈的运行栈来实现的,和静态存储的分配方式相反,在栈式存储方案中,程序对数据区的需求在编译时是完全未知的,只有到了运行的时候才能知道,但是规定在运行中进入一个程序模块的时候,必须知道该程序模块所需要的数据区的大小才能分配其内存。和我们在数据结构中所熟知的栈一样,栈式存储分配按照先进后出的原则进行分配。
堆式存储——堆式存储分配则专门负责在编译时或运行时模块入口处都无法确定存储要求的数据结构的内存分配,比如可变长度串和对象实例,堆由大片的可利用块或空闲块组成,堆中的内存可以按照任意顺序分配和释放。
[C++语言]对比C++语言里面,程序占用的内存分为下边几个部分:
[1]栈区(Stack):由编译器自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。我们在程序中定义的局部变量就是存放在栈里,当局部变量的生命周期结束的时候,它所占的内存会被自动释放。
[2]堆区(Heap):一般由程序员分配和释放,若程序员不释放,程序结束时可能由OS回收。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表。我们在程序中使用c++中new或者c中的malloc申请的一块内存,就是在heap上申请的,在使用完毕后,是需要我们自己动手释放的,否则就会产生“内存泄露”的问题。
[3]全局区(静态区)(Static):全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域,未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。程序结束后由系统释放。
[4]文字常量区:常量字符串就是放在这里的,程序结束后由系统释放。在Java中对应有一个字符串常量池。
[5]程序代码区:存放函数体的二进制代码
2)JVM结构【堆、栈解析】:
在Java虚拟机规范中,一个虚拟机实例的行为主要描述为:子系统、内存区域、数据类型和指令,这些组件在描述了抽象的JVM内部的一个抽象结构。与其说这些组成部分的目的是进行JVM内部结构的一种支配,更多的是提供一种严格定义实现的外部行为,该规范定义了这些抽象组成部分以及相互作用的任何Java虚拟机执行所需要的行为。下图描述了JVM内部的一个结构,其中主要包括主要的子系统、内存区域,如同以前在《Java基础知识》中描述的:Java虚拟机有一个类加载器作为JVM的子系统,类加载器针对Class进行检测以鉴定完全合格的类接口,而JVM内部也有一个执行引擎:
当JVM运行一个程序的时候,它的内存需要用来存储很多内容,包括字节码、以及从类文件中提取出来的一些附加信息、以及程序中实例化的对象、方法参数、返回值、局部变量以及计算的中间结果。JVM的内存组织需要在不同的运行时数据区进行以上的几个操作,下边针对上图里面出现的几个运行时数据区进行详细解析:一些运行时数据区共享了所有应用程序线程和其他特有的单个线程,每个JVM实例有一个方法区和一个内存堆,这些是共同在虚拟机内运行的线程。在Java程序里面,每个新的线程启动过后,它就会被JVM在内部分配自己的PC寄存器[PC registers](程序计数器器)和Java堆栈(Java stacks)。若该线程正在执行一个非本地Java方法,在PC寄存器的值指示下一条指令执行,该线程在Java内存栈中保存了非本地Java方法调用状态,其状态包括局部变量、被调用的参数、它的返回值、以及中间计算结果。而本地方法调用的状态则是存储在独立的本地方法内存栈里面(native method stacks),这种情况下使得这些本地方法和其他内存运行时数据区的内容尽可能保证和其他内存运行时数据区独立,而且该方法的调用更靠近操作系统,这些方法执行的字节码有可能根据操作系统环境的不同使得其编译出来的本地字节码的结构也有一定的差异。JVM中的内存栈是一个栈帧的组合,一个栈帧包含了某个Java方法调用的状态,当某个线程调用方法的时候,JVM就会将一个新的帧压入到Java内存栈,当方法调用完成过后,JVM将会从内存栈中移除该栈帧。JVM里面不存在一个可以存放中间计算数据结果值的寄存器,其内部指令集使用Java栈空间来存储中间计算的数据结果值,这种做法的设计是为了保持Java虚拟机的指令集紧凑,使得与寄存器原理能够紧密结合并且进行操作。
1)方法区(Method Area)
在JVM实例中,对装载的类型信息是存储在一个逻辑方法内存区中,当Java虚拟机加载了一个类型的时候,它会跟着这个Class的类型去路径里面查找对应的Class文件,类加载器读取类文件(线性二进制数据),然后将该文件传递给Java虚拟机,JVM从二进制数据中提取信息并且将这些信息存储在方法区,而类中声明(静态)变量就是来自于方法区中存储的信息。在JVM里面用什么样的方式存储该信息是由JVM设计的时候决定的,例如:当数据进入方法的时候,多类文件字节的存储量以Big-Endian(第一次最重要的字节)的顺序存储,尽管如此,一个虚拟机可以用任何方式针对这些数据进行存储操作,若它存储在一个Little-Endian处理器上,设计的时候就有可能将多文件字节的值按照Little-Endian顺寻存储。
——【$Big-Endian和Little-Endian】——
程序存储数据过程中,如果数据是跨越多个字节对象就必须有一种约定:
- 它的地址是多少:对于跨越多个字节的对象,一般它所占的字节都是连续的,它的地址等于它所占字节最低地址,这种情况链表可能存储的仅仅是表头
- 它的字节在内存中是如何组织的
比如:int x,它的地址为0x100,那么它占据了内存中的0x100、0x101、0x102、0x103四个字节,所以一般情况我们觉得int是4个字节。上边只是内存组织的一种情况,多字节对象在内存中的组织有两种约定,还有一种情况:若一个整数为W位,它的表示如下:
每一位表示为:[Xw-1,Xw-2,...,X1,X0]
它的最高有效字节MSB(Most Significant Byte)为:[Xw-1,Xw-2,...,Xw-8]
最低有效字节LSB(Least Significant Byte)为:[X7,X6,...,X0]
其余字节则位于LSB和MSB之间
LSB和MSB谁位于内存的最低地址,即代表了该对象的地址,这样就引出了Big-Endian和Little-Endian的问题,如果LSB在MSB前,LSB是最低地址,则该机器是小端,反之则是大端。DES(Digital Equipment Corporation,现在是Compaq公司的一部分)和Intel机器(x86平台)一般采用小端,IBM、Motorola(Power PC)、Sun的机器一般采用大端。当然这种不能代表所有情况,有的CPU既能工作于小端、又可以工作于大端,比如ARM、Alpha、摩托罗拉的PowerPC,这些情况根据具体的处理器型号有所不同。但是大部分操作系统(Windows、FreeBSD、Linux)一般都是Little Endian的,少部分系统(Mac OS)是Big Endian的,所以用什么方式存储还得依赖宿主操作系统环境。
由上图可以看到,映射访问(“写32位地址的0”)主要是由寄存器到内存、由内存到寄存器的一种数据映射方式,Big-Endian在上图可以看出的原子内存单位(Atomic Unit)在系统内存中的增长方向为从左到右,而Little-Endian的地址增长方向为从右到左。举个例子:
若要存储数据0x0A0B0C0D:
Big-Endian:
以8位为一个存储单位,其存储的地址增长为:
上图中可以看出MSB的值存储了0x0A,这种情况下数据的高位是从内存的低地址开始存储的,然后从左到右开始增长,第二位0x0B就是存储在第二位的,如果是按照16位为一个存储单位,其存储方式又为:
则可以看到Big-Endian的映射地址方式为:
MSB:在计算机中,最高有效位(MSB)是指位值的存储位置为转换为二进制数据后的最大值,MSB有时候在Big-Endian的架构中称为最左最大数据位,这种情况下再往左边的内存位则不是数据位了,而是有效位数位置的最高符号位,不仅仅如此,MSB也可以对应一个二进制符号位的符号位补码标记:“1”的含义为负,“0”的含义为正。最高位代表了“最重要字节”,也就是说当某些多字节数据拥有了最大值的时候它就是存储的时候最高位数据的字节对应的内存位置:
Little-Endian:
与Big-Endian相对的就是Little-Endian的存储方式,同样按照8位为一个存储单位上边的数据0x0A0B0C0D存储格式为:
可以看到LSB的值存储的0x0D,也就是数据的最低位是从内存的低地址开始存储的,它的高位是从右到左的顺序逐渐增加内存分配空间进行存储的,如果按照十六位为存储单位存储格式为:
从上图可以看到最低的16位的存储单位里面存储的值为0x0C0D,接着才是0x0A0B,这样就可以看到按照数据从高位到低位在内存中存储的时候是从右到左进行递增存储的,实际上可以从写内存的顺序来理解,实际上数据存储在内存中无非在使用的时候是写内存和读内存,针对LSB的方式最好的书面解释就是向左增加来看待,如果真正在进行内存读写的时候使用这样的顺序,其意义就体现出来了:
按照这种读写格式,0x0D存储在最低内存地址,而从右往左的增长就可以看到LSB存储的数据为0x0D,和初衷吻合,则十六位的存储就可以按照下边的格式来解释:
实际上从上边的存储还会考虑到另外一个问题,如果按照这种方式从右往左的方式进行存储,如果是遇到Unicode文字就和从左到右的语言显示方式相反。比如一个单词“XRAY”,使用Little-Endian的方式存储格式为:
使用这种方式进行内存读写的时候就会发现计算机语言和语言本身的顺序会有冲突,这种冲突主要是以使用语言的人的习惯有关,而书面化的语言从左到右就可以知道其冲突是不可避免的。我们一般使用语言的阅读方式都是从左到右,而低端存储(Little-Endian)的这种内存读写的方式使得我们最终从计算机里面读取字符需要进行倒序,而且考虑另外一个问题,如果是针对中文而言,一个字符是两个字节,就会出现整体顺序和每一个位的顺序会进行两次倒序操作,这种方式真正在制作处理器的时候也存在一种计算上的冲突,而针对使用文字从左到右进行阅读的国家而言,从右到左的方式(Big-Endian)则会有这样的文字冲突,另外一方面,尽管有很多国家使用语言是从右到左,但是仅仅和Big-Endian的方式存在冲突,这些国家毕竟占少数,所以可以理解的是,为什么主流的系统都是使用的Little-Endian的方式
【*:这里不解释Middle-Endian的方式以及Mixed-Endian的方式】
LSB:在计算机中,最低有效位是一个二进制给予单位的整数,位的位置确定了该数据是一个偶数还是奇数,LSB有时被称为最右位。在使用具体位二进制数之内,常见的存储方式就是每一位存储1或者0的方式,从0向上到1每一比特逢二进一的存储方式。LSB的这种特性用来指定单位位,而不是位的数字,而这种方式也有可能产生一定的混乱。
——以上是关于Big-Endian和Little-Endian的简单讲解——
JVM虚拟机将搜索和使用类型的一些信息也存储在方法区中以方便应用程序加载读取该数据。设计者在设计过程也考虑到要方便JVM进行Java应用程序的快速执行,而这种取舍主要是为了程序在运行过程中内存不足的情况能够通过一定的取舍去弥补内存不足的情况。在JVM内部,所有的线程共享相同的方法区,因此,访问方法区的数据结构必须是线程安全的,如果两个线程都试图去调用去找一个名为Lava的类,比如Lava还没有被加载,只有一个线程可以加载该类而另外的线程只能够等待。方法区的大小在分配过程中是不固定的,随着Java应用程序的运行,JVM可以调整其大小,需要注意一点,方法区的内存不需要是连续的,因为方法区内存可以分配在内存堆中,即使是虚拟机JVM实例对象自己所在的内存堆也是可行的,而在实现过程是允许程序员自身来指定方法区的初始化大小的。
同样的,因为Java本身的自动内存管理,方法区也会被垃圾回收的,Java程序可以通过类扩展动态加载器对象,类可以成为“未引用”向垃圾回收器进行申请,如果一个类是“未引用”的,则该类就可能被卸载,
而方法区针对具体的语言特性有几种信息是存储在方法区内的:
【类型信息】:
- 类型的完全限定名(java.lang.String格式)
- 类型的完全限定名的直接父类的完全限定名(除非这个父类的类型是一个接口或者java.lang.Object)
- 不论类型是一个类或者接口
- 类型的修饰符(例如public、abstract、final)
- 任何一个直接超类接口的完全限定名的列表
在JVM和类文件名的内部,类型名一般都是完全限定名(java.lang.String)格式,在Java源文件里面,完全限定名必须加入包前缀,而不是我们在开发过程写的简单类名,而在方法上,只要是符合Java语言规范的类的完全限定名都可以,而JVM可能直接进行解析,比如:(java.lang.String)在JVM内部名称为java/lang/String,这就是我们在异常捕捉的时候经常看到的ClassNotFoundException的异常里面类信息的名称格式。
除此之外,还必须为每一种加载过的类型在JVM内进行存储,下边的信息不存储在方法区内,下边的章节会一一说明
- 类型常量池
- 字段信息
- 方法信息
- 所有定义在Class内部的(静态)变量信息,除开常量
- 一个ClassLoader的引用
- Class的引用
【常量池】
针对类型加载的类型信息,JVM将这些存储在常量池里,常量池是一个根据类型定义的常量的有序常量集,包括字面量(String、Integer、Float常量)以及符号引用(类型、字段、方法),整个长量池会被JVM的一个索引引用,如同数组里面的元素集合按照索引访问一样,JVM针对这些常量池里面存储的信息也是按照索引方式进行。实际上长量池在Java程序的动态链接过程起到了一个至关重要的作用。
【字段信息】
针对字段的类型信息,下边的信息是存储在方法区里面的:
- 字段名
- 字段类型
- 字段修饰符(public,private,protected,static,final,volatile,transient)
【方法信息】
针对方法信息,下边信息存储在方法区上:
- 方法名
- 方法的返回类型(包括void)
- 方法参数的类型、数目以及顺序
- 方法修饰符(public,private,protected,static,final,synchronized,native,abstract)
针对非本地方法,还有些附加方法信息需要存储在方法区内:
- 方法字节码
- 方法中局部变量区的大小、方法栈帧
- 异常表
【类变量】
类变量在一个类的多个实例之间共享,这些变量直接和类相关,而不是和类的实例相关,(定义过程简单理解为类里面定义的static类型的变量),针对类变量,其逻辑部分就是存储在方法区内的。在JVM使用这些类之前,JVM先要在方法区里面为定义的non-final变量分配内存空间;常量(定义为final)则在JVM内部则不是以同样的方式来进行存储的,尽管针对常量而言,一个final的类变量是拥有它自己的常量池,作为常量池里面的存储某部分,类常量是存储在方法区内的,而其逻辑部分则不是按照上边的类变量的方式来进行内存分配的。虽然non-final类变量是作为这些类型声明中存储数据的某一部分,final变量存储为任何使用它类型的一部分的数据格式进行简单存储。
【ClassLoader引用】
对于每种类型的加载,JVM必须检测其类型是否符合了JVM的语言规范,对于通过类加载器加载的对象类型,JVM必须存储对类的引用,而这些针对类加载器的引用是作为了方法区里面的类型数据部分进行存储的。
【类Class的引用】
JVM在加载了任何一个类型过后会创建一个java.lang.Class的实例,虚拟机必须通过一定的途径来引用该类型对应的一个Class的实例,并且将其存储在方法区内
【方法表】
为了提高访问效率,必须仔细的设计存储在方法区中的数据信息结构。除了以上讨论的结构,jvm的实现者还添加一些其他的数据结构,如方法表【下边会说明】。
2)内存栈(Stack):
当一个新线程启动的时候,JVM会为Java线程创建每个线程的独立内存栈,如前所言Java的内存栈是由栈帧构成,栈帧本身处于游离状态,在JVM里面,栈帧的操作只有两种:出栈和入栈。正在被线程执行的方法一般称为当前线程方法,而该方法的栈帧就称为当前帧,而在该方法内定义的类称为当前类,常量池也称为当前常量池。当执行一个方法如此的时候,JVM保留当前类和当前常量池的跟踪,当虚拟机遇到了存储在栈帧中的数据上的操作指令的时候,它就执行当前帧的操作。当一个线程调用某个Java方法时,虚拟机创建并且将一个新帧压入到内存堆栈中,而这个压入到内存栈中的帧成为当前栈帧,当该方法执行的时候,JVM使用内存栈来存储参数、局部变量、中间计算结果以及其他相关数据。方法在执行过程有可能因为两种方式而结束:如果一个方法返回完成就属于方法执行的正常结束,如果在这个过程抛出异常而结束,可以称为非正常结束,不论是正常结束还是异常结束,JVM都会弹出或者丢弃该栈帧,则上一帧的方法就成为了当前帧。
在JVM中,Java线程的栈数据是属于某个线程独有的,其他的线程不能够修改或者通过其他方式来访问该线程的栈帧,正因为如此这种情况不用担心多线程同步访问Java的局部变量,当一个线程调用某个方法的时候,方法的局部变量是在方法内部进行的Java栈帧的存储,只有当前线程可以访问该局部变量,而其他线程不能随便访问该内存栈里面存储的数据。内存栈内的栈帧数据和方法区以及内存堆一样,Java栈的栈帧不需要分配在连续的堆栈内,或者说它们可能是在堆,或者两者组合分配,实际数据用于表示Java堆栈和栈帧结构是JVM本身的设计结构决定的,而且在编程过程可以允许程序员指定一个用于Java堆栈的初始大小以及最大、最小尺寸。
【概念区分】
- 内存栈:这里的内存栈和物理结构内存堆栈有点点区别,是内存里面数据存储的一种抽象数据结构。从操作系统上讲,在程序执行过程对内存的使用本身常用的数据结构就是内存堆栈,而这里的内存堆栈指代的就是JVM在使用内存过程整个内存的存储结构,多指内存的物理结构,而Java内存栈不是指代的一个物理结构,更多的时候指代的是一个抽象结构,就是符合JVM语言规范的内存栈的一个抽象结构。因为物理内存堆栈结构和Java内存栈的抽象模型结构本身比较相似,所以我们在学习过程就正常把这两种结构放在一起考虑了,而且二者除了概念上有一点点小的区别,理解成为一种结构对于初学者也未尝不可,所以实际上也可以觉得二者没有太大的本质区别。但是在学习的时候最好分清楚内存堆栈和Java内存栈的一小点细微的差距,前者是物理概念和本身模型,后者是抽象概念和本身模型的一个共同体。而内存堆栈更多的说法可以理解为一个内存块,因为内存块可以通过索引和指针进行数据结构的组合,内存栈就是内存块针对数据结构的一种表示,而内存堆则是内存块的另外一种数据结构的表示,这样理解更容易区分内存栈和内存堆栈(内存块)的概念。
- 栈帧:栈帧是内存栈里面的最小单位,指的是内存栈里面每一个最小内存存储单元,它针对内存栈仅仅做了两个操作:入栈和出栈,一般情况下:所说的堆栈帧和栈帧倒是一个概念,所以在理解上记得加以区分
- 内存堆:这里的内存堆和内存栈是相对应的,其实内存堆里面的数据也是存储在系统内存堆栈里面的,只是它使用了另外一种方式来进行堆里面内存的管理,而本章题目要讲到的就是Java语言本身的内存堆和内存栈,而这两个概念都是抽象的概念模型,而且是相对的。
栈帧:栈帧主要包括三个部分:局部变量、操作数栈帧(操作帧)和帧数据(数据帧)。本地变量和操作数帧的大小取决于需要,这些大小是在编译时就决定的,并且在每个方法的类文件数据中进行分配,帧的数据大小则不一样,它虽然也是在编译时就决定的但是它的大小和本身代码实现有关。当JVM调用一个Java方法的时候,它会检查类的数据来确定在本地变量和操作方法要求的栈大小,它计算该方法所需要的内存大小,然后将这些数据分配好内存空间压入到内存堆栈中。
栈帧——局部变量:局部变量是以Java栈帧组合成为的一个以零为基的数组,使用局部变量的时候使用的实际上是一个包含了0的一个基于索引的数组结构。int类型、float、引用以及返回值都占据了一个数组中的局部变量的条目,而byte、short、char则在存储到局部变量的时候是先转化成为int再进行操作的,则long和double则是在这样一个数组里面使用了两个元素的空间大小,在局部变量里面存储基本数据类型的时候使用的就是这样的结构。举个例子:
class Example3a{
public static int runClassMethod(int i,long l,float f,double d,Object o,byte b)
{
return 0;
}
public int runInstanceMethod(char c,double d,short s,boolean b)
{
return 0;
}
}
栈帧——操作帧:和局部变量一样,操作帧也是一组有组织的数组的存储结构,但是和局部变量不一样的是这个不是通过数组的索引访问的,而是直接进行的入栈和出栈的操作,当操作指令直接压入了操作栈帧过后,从栈帧里面出来的数据会直接在出栈的时候被读取和使用。除了程序计数器以外,操作帧也是可以直接被指令访问到的,JVM里面没有寄存器。处理操作帧的时候Java虚拟机是基于内存栈的而不是基于寄存器的,因为它在操作过程是直接对内存栈进行操作而不是针对寄存器进行操作。而JVM内部的指令也可以来源于其他地方比如紧接着操作符以及操作数的字节码流或者直接从常量池里面进行操作。JVM指令其实真正在操作过程的焦点是集中在内存栈栈帧的操作帧上的。JVM指令将操作帧作为一个工作空间,有许多指令都是从操作帧里面出栈读取的,对指令进行操作过后将操作帧的计算结果重新压入内存堆栈内。比如iadd指令将两个整数压入到操作帧里面,然后将两个操作数进行相加,相加的时候从内存栈里面读取两个操作数的值,然后进行运算,最后将运算结果重新存入到内存堆栈里面。举个简单的例子:
begin
iload_0 //将整数类型的局部变量0压入到内存栈里面
iload_1 //将整数类型的局部变量1压入到内存栈里面
iadd //将两个变量出栈读取,然后进行相加操作,将结果重新压入栈中
istore_2 //将最终输出结果放在另外一个局部变量里面
end
综上所述,就是整个计算过程针对内存的一些操作内容,而整体的结构可以用下图来描述:
栈帧——数据帧:除了局部变量和操作帧以外,Java栈帧还包括了数据帧,用于支持常量池、普通的方法返回以及异常抛出等,这些数据都是存储在Java内存栈帧的数据帧中的。很多JVM的指令集实际上使用的都是常量池里面的一些条目,一些指令,只是把int、long、float、double或者String从常量池里面压入到Java栈帧的操作帧上边,一些指令使用常量池来管理类或者数组的实例化操作、字段的访问控制、或者方法的调用,其他的指令就用来决定常量池条目中记录的某一特定对象是否某一类或者常量池项中指定的接口。常量池会判断类型、字段、方法、类、接口、类字段以及引用是如何在JVM进行符号化描述,而这个过程由JVM本身进行对应的判断。这里就可以理解JVM如何来判断我们通常说的:“原始变量存储在内存栈上,而引用的对象存储在内存堆上边。”除了常量池判断帧数据符号化描述特性以外,这些数据帧必须在JVM正常执行或者异常执行过程辅助它进行处理操作。如果一个方法是正常结束的,JVM必须恢复栈帧调用方法的数据帧,而且必须设置PC寄存器指向调用方法后边等待的指令完成该调用方法的位置。如果该方法存在返回值,JVM也必须将这个值压入到操作帧里面以提供给需要这些数据的方法进行调用。不仅仅如此,数据帧也必须提供一个方法调用的异常表,当JVM在方法中抛出异常而非正常结束的时候,该异常表就用来存放异常信息。
3)内存堆(Heap):
当一个Java应用程序在运行的时候在程序中创建一个对象或者一个数组的时候,JVM会针对该对象和数组分配一个新的内存堆空间。但是在JVM实例内部,只存在一个内存堆实例,所有的依赖该JVM的Java应用程序都需要共享该堆实例,而Java应用程序本身在运行的时候它自己包含了一个由JVM虚拟机实例分配的自己的堆空间,而在应用程序启动的时候,任何一个Java应用程序都会得到JVM分配的堆空间,而且针对每一个Java应用程序,这些运行Java应用程序的堆空间都是相互独立的。这里所提及到的共享堆实例是指JVM在初始化运行的时候整体堆空间只有一个,这个是Java语言平台直接从操作系统上能够拿到的整体堆空间,所以的依赖该JVM的程序都可以得到这些内存空间,但是针对每一个独立的Java应用程序而言,这些堆空间是相互独立的,每一个Java应用程序在运行最初都是依靠JVM来进行堆空间的分配的。即使是两个相同的Java应用程序,一旦在运行的时候处于不同的操作系统进程(一般为java.exe)中,它们各自分配的堆空间都是独立的,不能相互访问,只是两个Java应用进程初始化拿到的堆空间来自JVM的分配,而JVM是从最初的内存堆实例里面分配出来的。在同一个Java应用程序里面如果出现了不同的线程,则是可以共享每一个Java应用程序拿到的内存堆空间的,这也是为什么在开发多线程程序的时候,针对同一个Java应用程序必须考虑线程安全问题,因为在一个Java进程里面所有的线程是可以共享这个进程拿到的堆空间的数据的。但是Java内存堆有一个特性,就是JVM拥有针对新的对象分配内存的指令,但是它却不包含释放该内存空间的指令,当然开发过程可以在Java源代码中显示释放内存或者说在JVM字节码中进行显示的内存释放,但是JVM仅仅只是检测堆空间中是否有引用不可达(不可以引用)的对象,然后将接下来的操作交给垃圾回收器来处理。
对象表示:
JVM规范里面并没有提及到Java对象如何在堆空间中表示和描述,对象表示可以理解为设计JVM的工程师在最初考虑到对象调用以及垃圾回收器针对对象的判断而独立的一种Java对象在内存中的存储结构,该结构是由设计最初考虑的。针对一个创建的类实例而言,它内部定义的实例变量以及它的超类以及一些相关的核心数据,是必须通过一定的途径进行该对象内部存储以及表示的。当开发过程给定了一个对象引用的时候,JVM必须能够通过这个引用快速从对象堆空间中去拿到该对象能够访问的数据内容。也就是说,堆空间内对象的存储结构必须为外围对象引用提供一种可以访问该对象以及控制该对象的接口使得引用能够顺利地调用该对象以及相关操作。因此,针对堆空间的对象,分配的内存中往往也包含了一些指向方法区的指针,因为从整体存储结构上讲,方法区似乎存储了很多原子级别的内容,包括方法区内最原始最单一的一些变量:比如类字段、字段数据、类型数据等等。而JVM本身针对堆空间的管理存在两种设计结构:
【1】设计一:
堆空间的设计可以划分为两个部分:一个处理池和一个对象池,一个对象的引用可以拿到处理池的一个本地指针,而处理池主要分为两个部分:一个指向对象池里面的指针以及一个指向方法区的指针。这种结构的优势在于JVM在处理对象的时候,更加能够方便地组合堆碎片以使得所有的数据被更加方便地进行调用。当JVM需要将一个对象移动到对象池的时候,它仅仅需要更新该对象的指针到一个新的对象池的内存地址中就可以完成了,然后在处理池中针对该对象的内部结构进行相对应的处理工作。不过这样的方法也会出现一个缺点就是在处理一个对象的时候针对对象的访问需要提供两个不同的指针,这一点可能不好理解,其实可以这样讲,真正在对象处理过程存在一个根据时间戳有区别的对象状态,而对象在移动、更新以及创建的整个过程中,它的处理池里面总是包含了两个指针,一个指针是指向对象内容本身,一个指针是指向了方法区,因为一个完整的对外的对象是依靠这两部分被引用指针引用到的,而我们开发过程是不能够操作处理池的两个指针的,只有引用指针我们可以通过外围编程拿到。如果Java是按照这种设计进行对象存储,这里的引用指针就是平时提及到的“Java的引用”,只是JVM在引用指针还做了一定的封装,这种封装的规则是JVM本身设计的时候做的,它就通过这种结构在外围进行一次封装,比如Java引用不具备直接操作内存地址的能力就是该封装的一种限制规则。这种设计的结构图如下:
【2】设计二:
另外一种堆空间设计就是使用对象引用拿到的本地指针,将该指针直接指向绑定好的对象的实例数据,这些数据里面仅仅包含了一个指向方法区原子级别的数据去拿到该实例相关数据,这种情况下只需要引用一个指针来访问对象实例数据,但是这样的情况使得对象的移动以及对象的数据更新变得更加复杂。当JVM需要移动这些数据以及进行堆内存碎片的整理的时候,就必须直接更新该对象所有运行时的数据区,这种情况可以用下图进行表示:
JVM需要从一个对象引用来获得该引用能够引用的对象数据存在多个原因,当一个程序试图将一个对象的引用转换成为另外一个类型的时候,JVM就会检查两个引用指向的对象是否存在父子类关系,并且检查两个引用引用到的对象是否能够进行类型转换,而且所有这种类型的转换必须执行同样的一个操作:instanceof操作,在上边两种情况下,JVM都必须要去分析引用指向的对象内部的数据。当一个程序调用了一个实例方法的时候,JVM就必须进行动态绑定操作,它必须选择调用方法的引用类型,是一个基于类的方法调用还是一个基于对象的方法调用,要做到这一点,它又要获取该对象的唯一引用才可以。不管对象的实现是使用什么方式来进行对象描述,都是在针对内存中关于该对象的方法表进行操作,因为使用这样的方式加快了实例针对方法的调用,而且在JVM内部实现的时候这样的机制使得其运行表现比较良好,所以方法表的设计在JVM整体结构中发挥了极其重要的作用。关于方法表的存在与否,在JVM规范里面没有严格说明,也有可能真正在实现过程只是一个抽象概念,物理层它根本不存在,针对放发表实现对于一个创建的实例而言,它本身具有不太高的内存需要求,如果该实现里面使用了方法表,则对象的方法表应该是可以很快被外围引用访问到的。
有一种办法就是通过对象引用连接到方法表的时候,如下图:
该图表明,在每个指针指向一个对象的时候,实际上是使用的一个特殊的数据结构,这些特殊的结构包括几个部分:
- 一个指向该对象类所有数据的指针
- 该对象的方法表
实际上从图中可以看出,方法表就是一个指针数组,它的每一个元素包含了一个指针,针对每个对象的方法都可以直接通过该指针在方法区中找到匹配的数据进行相关调用,而这些方法表需要包括的内容如下:
- 方法内存堆栈段空间中操作栈的大小以及局部变量
- 方法字节码
- 一个方法的异常表
这些信息使得JVM足够针对该方法进行调用,在调用过程,这种结构也能够方便子类对象的方法直接通过指针引用到父类的一些方法定义,也就是说指针在内存空间之内通过JVM本身的调用使得父类的一些方法表也可以同样的方式被调用,当然这种调用过程避免不了两个对象之间的类型检查,但是这样的方式就使得继承的实现变得更加简单,而且方法表提供的这些数据足够引用对对象进行带有任何OO特征的对象操作。
另外一种数据在上边的途中没有显示出来,也是从逻辑上讲内存堆中的对象的真实数据结构——对象的锁。这一点可能需要关联到JMM模型中讲的进行理解。JVM中的每一个对象都是和一个锁(互斥)相关联的,这种结构使得该对象可以很容易支持多线程访问,而且该对象的对象锁一次只能被一个线程访问。当一个线程在运行的时候具有某个对象的锁的时候,仅仅只有这个线程可以访问该对象的实例变量,其他线程如果需要访问该实例的实例变量就必须等待这个线程将它占有的对象锁释放过后才能够正常访问,如果一个线程请求了一个被其他线程占有的对象锁,这个请求线程也必须等到该锁被释放过后才能够拿到这个对象的对象锁。一旦这个线程拥有了一个对象锁过后,它自己可以多次向同一个锁发送对象的锁请求,但是如果它要使得被该线程锁住的对象可以被其他锁访问到的话就需要同样的释放锁的次数,比如线程A请求了对象B的对象锁三次,那么A将会一直占有B对象的对象锁,直到它将该对象锁释放了三次。
很多对象也可能在整个生命周期都没有被对象锁锁住过,在这样的情况下对象锁相关的数据是不需要对象内部实现的,除非有线程向该对象请求了对象锁,否则这个对象就没有该对象锁的存储结构。所以上边的实现图可以知道,很多实现不包括指向对象锁的“锁数据”,锁数据的实现必须要等待某个线程向该对象发送了对象锁请求过后,而且是在第一次锁请求过后才会被实现。这个结构中,JVM却能够间接地通过一些办法针对对象的锁进行管理,比如把对象锁放在基于对象地址的搜索树上边。实现了锁结构的对象中,每一个Java对象逻辑上都在内存中成为了一个等待集,这样就使得所有的线程在锁结构里面针对对象内部数据可以独立操作,等待集就使得每个线程能够独立于其他线程去完成一个共同的设计目标以及程序执行的最终结果,这样就使得多线程的线程独享数据以及线程共享数据机制很容易实现。
不仅仅如此,针对内存堆对象还必须存在一个对象的镜像,该镜像的主要目的是提供给垃圾回收器进行监控操作,垃圾回收器是通过对象的状态来判断该对象是否被应用,同样它需要针对堆内的对象进行监控。而当监控过程垃圾回收器收到对象回收的事件触发的时候,虽然使用了不同的垃圾回收算法,不论使用什么算法都需要通过独有的机制来判断对象目前处于哪种状态,然后根据对象状态进行操作。开发过程程序员往往不会去仔细分析当一个对象引用设置成为null了过后虚拟机内部的操作,但实际上Java里面的引用往往不像我们想像中那么简单,Java引用中的虚引用、弱引用就是使得Java引用在显示提交可回收状态的情况下对内存堆中的对象进行的反向监控,这些引用可以监视到垃圾回收器回收该对象的过程。垃圾回收器本身的实现也是需要内存堆中的对象能够提供相对应的数据的。其实这个位置到底JVM里面是否使用了完整的Java对象的镜像还是使用的一个镜像索引我没有去仔细分析过,总之是在堆结构里面存在着堆内对象的一个类似拷贝的镜像机制,使得垃圾回收器能够顺利回收不再被引用的对象。
4)内存栈和内存堆的实现原理探测【该部分为不确定概念】:
实际上不论是内存栈结构、方法区还是内存堆结构,归根到底使用的是操作系统的内存,操作系统的内存结构可以理解为内存块,常用的抽象方式就是一个内存堆栈,而JVM在OS上边安装了过后,就在启动Java程序的时候按照配置文件里面的内容向操作系统申请内存空间,该内存空间会按照JVM内部的方法提供相应的结构调整。
内存栈应该是很容易理解的结构实现,一般情况下,内存栈是保持连续的,但是不绝对,内存栈申请到的地址实际上很多情况下都是连续的,而每个地址的最小单位是按照计算机位来算的,该计算机位里面只有两种状态1和0,而内存栈的使用过程就是典型的类似C++里面的普通指针结构的使用过程,直接针对指针进行++或者--操作就修改了该指针针对内存的偏移量,而这些偏移量就使得该指针可以调用不同的内存栈中的数据。至于针对内存栈发送的指令就是常见的计算机指令,而这些指令就使得该指针针对内存栈的栈帧进行指令发送,比如发送操作指令、变量读取等等,直接就使得内存栈的调用变得更加简单,而且栈帧在接受了该数据过后就知道到底针对栈帧内部的哪一个部分进行调用,是操作帧、数据帧还是局部变量。
内存堆实际上在操作系统里面使用了双向链表的数据结构,双向链表的结构使得即使内存堆不具有连续性,每一个堆空间里面的链表也可以进入下一个堆空间,而操作系统本身在整理内存堆的时候会做一些简单的操作,然后通过每一个内存堆的双向链表就使得内存堆更加方便。而且堆空间不需要有序,甚至说有序不影响堆空间的存储结构,因为它归根到底是在内存块上边进行实现的,内存块本身是一个堆栈结构,只是该内存堆栈里面的块如何分配不由JVM决定,是由操作系统已经最开始分配好了,也就是最小存储单位。然后JVM拿到从操作系统申请的堆空间过后,先进行初始化操作,然后就可以直接使用了。
常见的对程序有影响的内存问题主要是两种:溢出和内存泄漏,上边已经讲过了内存泄漏,其实从内存的结构分析,泄漏这种情况很难甚至说不可能发生在栈空间里面,其主要原因是栈空间本身很难出现悬停的内存,因为栈空间的存储结构有可能是内存的一个地址数组,所以在访问栈空间的时候使用的都是索引或者下标或者就是最原始的出栈和入栈的操作,这些操作使得栈里面很难出现像堆空间一样的内存悬停(也就是引用悬挂)问题。堆空间悬停的内存是因为栈中存放的引用的变化,其实引用可以理解为从栈到堆的一个指针,当该指针发生变化的时候,堆内存碎片就有可能产生,而这种情况下在原始语言里面就经常发生内存泄漏的情况,因为这些悬停的堆空间在系统里面是不能够被任何本地指针引用到,就使得这些对象在未被回收的时候脱离了可操作区域并且占用了系统资源。
栈溢出问题一直都是计算机领域里面的一个安全性问题,这里不做深入讨论,说多了就偏离主题了,而内存泄漏是程序员最容易理解的内存问题,还有一个问题来自于我一个黑客朋友就是:堆溢出现象,这种现象可能更加复杂。
其实Java里面的内存结构,最初看来就是堆和栈的结合,实际上可以这样理解,实际上对象的实际内容才存在对象池里面,而有关对象的其他东西有可能会存储于方法区,而平时使用的时候的引用是存在内存栈上的,这样就更加容易理解它内部的结构,不仅仅如此,有时候还需要考虑到Java里面的一些字段和属性到底是对象域的还是类域的,这个也是一个比较复杂的问题。
二者的区别简单总结一下:
- 管理方式:JVM自己可以针对内存栈进行管理操作,而且该内存空间的释放是编译器就可以操作的内容,而堆空间在Java中JVM本身执行引擎不会对其进行释放操作,而是让垃圾回收器进行自动回收
- 空间大小:一般情况下栈空间相对于堆空间而言比较小,这是由栈空间里面存储的数据以及本身需要的数据特性决定的,而堆空间在JVM堆实例进行分配的时候一般大小都比较大,因为堆空间在一个Java程序中需要存储太多的Java对象数据
- 碎片相关:针对堆空间而言,即使垃圾回收器能够进行自动堆内存回收,但是堆空间的活动量相对栈空间而言比较大,很有可能存在长期的堆空间分配和释放操作,而且垃圾回收器不是实时的,它有可能使得堆空间的内存碎片主键累积起来。针对栈空间而言,因为它本身就是一个堆栈的数据结构,它的操作都是一一对应的,而且每一个最小单位的结构栈帧和堆空间内复杂的内存结构不一样,所以它一般在使用过程很少出现内存碎片。
- 分配方式:一般情况下,栈空间有两种分配方式:静态分配和动态分配,静态分配是本身由编译器分配好了,而动态分配可能根据情况有所不同,而堆空间却是完全的动态分配的,是一个运行时级别的内存分配。而栈空间分配的内存不需要我们考虑释放问题,而堆空间即使在有垃圾回收器的前提下还是要考虑其释放问题。
- 效率:因为内存块本身的排列就是一个典型的堆栈结构,所以栈空间的效率自然比起堆空间要高很多,而且计算机底层内存空间本身就使用了最基础的堆栈结构使得栈空间和底层结构更加符合,它的操作也变得简单就是最简单的两个指令:入栈和出栈;栈空间针对堆空间而言的弱点是灵活程度不够,特别是在动态管理的时候。而堆空间最大的优势在于动态分配,因为它在计算机底层实现可能是一个双向链表结构,所以它在管理的时候操作比栈空间复杂很多,自然它的灵活度就高了,但是这样的设计也使得堆空间的效率不如栈空间,而且低很多。
3.本机内存[部分内容来源于IBM开发中心]
Java堆空间是在编写Java程序中被我们使用得最频繁的内存空间,平时开发过程,开发人员一定遇到过OutOfMemoryError,这种结果有可能来源于Java堆空间的内存泄漏,也可能是因为堆的大小不够而导致的,有时候这些错误是可以依靠开发人员修复的,但是随着Java程序需要处理越来越多的并发程序,可能有些错误就不是那么容易处理了。有些时候即使Java堆空间没有满也可能抛出错误,这种情况下需要了解的就是JRE(Java Runtime Environment)内部到底发生了什么。Java本身的运行宿主环境并不是操作系统,而是Java虚拟机,Java虚拟机本身是用C编写的本机程序,自然它会调用到本机资源,最常见的就是针对本机内存的调用。本机内存是可以用于运行时进程的,它和Java应用程序使用的Java堆内存不一样,每一种虚拟化资源都必须存储在本机内存里面,包括虚拟机本身运行的数据,这样也意味着主机的硬件和操作系统在本机内存的限制将直接影响到Java应用程序的性能。
i.Java运行时如何使用本机内存:
1)堆空间和垃圾回收
Java运行时是一个操作系统进程(Windows下一般为java.exe),该环境提供的功能会受一些位置的用户代码驱动,这虽然提高了运行时在处理资源的灵活性,但是无法预测每种情况下运行时环境需要何种资源,这一点Java堆空间讲解中已经提到过了。在Java命令行可以使用-Xmx和-Xms来控制堆空间初始配置,mx表示堆空间的最大大小,ms表示初始化大小,这也是上提到的启动Java的配置文件可以配置的内容。尽管逻辑内存堆可以根据堆上的对象数量和在GC上花费的时间增加或者减少,但是使用本机内存的大小是保持不变的,而且由-Xms的值指定,大部分GC算法都是依赖被分配的连续内存块的堆空间,因此不能在堆需要扩大的时候分配更多的本机内存,所有的堆内存必须保留下来,请注意这里说的不是Java堆内存空间是本机内存。
本机内存保留和本机内存分配不一样,本机内存被保留的时候,无法使用物理内存或者其他存储器作为备用内存,尽管保留地址空间块不会耗尽物理资源,但是会阻止内存用于其他用途,由保留从未使用过的内存导致的泄漏和泄漏分配的内存造成的问题其严重程度差不多,但使用的堆区域缩小时,一些垃圾回收器会回收堆空间的一部分内容,从而减少物理内存的使用。对于维护Java堆的内存管理系统,需要更多的本机内存来维护它的状态,进行垃圾收集的时候,必须分配数据结构来跟踪空闲存储空间和进度记录,这些数据结构的确切大小和性质因实现的不同而有所差异。
2)JIT
JIT编译器在运行时编译Java字节码来优化本机可执行代码,这样极大提高了Java运行时的速度,并且支持Java应用程序与本地代码相当的速度运行。字节码编译使用本机内存,而且JIT编译器的输入(字节码)和输出(可执行代码)也必须存储在本机内存里面,包含了多个经过JIT编译的方法的Java程序会比一些小型应用程序使用更多的本机内存。
3)类和类加载器
Java 应用程序由一些类组成,这些类定义对象结构和方法逻辑。Java 应用程序也使用 Java 运行时类库(比如 java.lang.String)中的类,也可以使用第三方库。这些类需要存储在内存中以备使用。存储类的方式取决于具体实现。Sun JDK 使用永久生成(permanent generation,PermGen)堆区域,从最基本的层面来看,使用更多的类将需要使用更多内存。(这可能意味着您的本机内存使用量会增加,或者您必须明确地重新设置 PermGen 或共享类缓存等区域的大小,以装入所有类)。记住,不仅您的应用程序需要加载到内存中,框架、应用服务器、第三方库以及包含类的 Java 运行时也会按需加载并占用空间。Java 运行时可以卸载类来回收空间,但是只有在非常严酷的条件下才会这样做,不能卸载单个类,而是卸载类加载器,随其加载的所有类都会被卸载。只有在以下情况下才能卸载类加载器
- Java 堆不包含对表示该类加载器的 java.lang.ClassLoader 对象的引用。
- Java 堆不包含对表示类加载器加载的类的任何 java.lang.Class 对象的引用。
- 在 Java 堆上,该类加载器加载的任何类的所有对象都不再存活(被引用)。
需要注意的是,Java 运行时为所有 Java 应用程序创建的 3 个默认类加载器( bootstrap、extension 和 application )都不可能满足这些条件,因此,任何系统类(比如 java.lang.String)或通过应用程序类加载器加载的任何应用程序类都不能在运行时释放。即使类加载器适合进行收集,运行时也只会将收集类加载器作为 GC 周期的一部分。一些实现只会在某些 GC 周期中卸载类加载器,也可能在运行时生成类,而不去释放它。许多 Java EE 应用程序使用 JavaServer Pages (JSP) 技术来生成 Web 页面。使用 JSP 会为执行的每个 .jsp 页面生成一个类,并且这些类会在加载它们的类加载器的整个生存期中一直存在 —— 这个生存期通常是 Web 应用程序的生存期。另一种生成类的常见方法是使用 Java 反射。反射的工作方式因 Java 实现的不同而不同,当使用 java.lang.reflect API 时,Java 运行时必须将一个反射对象(比如 java.lang.reflect.Field)的方法连接到被反射到的对象或类。这可以通过使用 Java 本机接口(Java Native Interface,JNI)访问器来完成,这种方法需要的设置很少,但是速度缓慢,也可以在运行时为您想要反射到的每种对象类型动态构建一个类。后一种方法在设置上更慢,但运行速度更快,非常适合于经常反射到一个特定类的应用程序。Java 运行时在最初几次反射到一个类时使用 JNI 方法,但当使用了若干次 JNI 方法之后,访问器会膨胀为字节码访问器,这涉及到构建类并通过新的类加载器进行加载。执行多次反射可能导致创建了许多访问器类和类加载器,保持对反射对象的引用会导致这些类一直存活,并继续占用空间,因为创建字节码访问器非常缓慢,所以 Java 运行时可以缓存这些访问器以备以后使用,一些应用程序和框架还会缓存反射对象,这进一步增加了它们的本机内存占用。
4)JNI
JNI支持本机代码调用Java方法,反之亦然,Java运行时本身极大依赖于JNI代码来实现类库功能,比如文件和网络I/O,JNI应用程序可以通过三种方式增加Java运行时对本机内存的使用:
- JNI应用程序的本机代码被编译到共享库中,或编译为加载到进程地址空间中的可执行文件,大型本机应用程序可能仅仅加载就会占用大量进程地址空间
- 本机代码必须与Java运行时共享地址空间,任何本机代码分配或本机代码执行的内存映射都会耗用Java运行时内存
- 某些JNI函数可能在它们的常规操作中使用本机内存,GetTypeArrayElements和GetTypeArrayRegion函数可以将Java堆复制到本机内存缓冲区中,提供给本地代码使用,是否复制数据依赖于运行时实现,通过这种方式访问大量Java堆数据就可能使用大量的本机内存堆空间
5)NIO
JDK 1.4开始添加了新的I/O类,引入了一种基于通道和缓冲区执行I/O的新方式,就像Java堆上的内存支持I/O缓冲区一样,NIO添加了对直接ByteBuffer的支持,ByteBuffer受本机内存而不是Java堆的支持,直接ByteBuffer可以直接传递到本机操作系统库函数,以执行I/O,这种情况虽然提高了Java程序在I/O的执行效率,但是会对本机内存进行直接的内存开销。ByteBuffer直接操作和非直接操作的区别如下:
对于在何处存储直接 ByteBuffer 数据,很容易产生混淆。应用程序仍然在 Java 堆上使用一个对象来编排 I/O 操作,但持有该数据的缓冲区将保存在本机内存中,Java 堆对象仅包含对本机堆缓冲区的引用。非直接 ByteBuffer 将其数据保存在 Java 堆上的 byte[] 数组中。直接ByteBuffer对象会自动清理本机缓冲区,但这个过程只能作为Java堆GC的一部分执行,它不会自动影响施加在本机上的压力。GC仅在Java堆被填满,以至于无法为堆分配请求提供服务的时候,或者在Java应用程序中显示请求它发生。
6)线程:
应用程序中的每个线程都需要内存来存储器堆栈(用于在调用函数时持有局部变量并维护状态的内存区域)。每个 Java 线程都需要堆栈空间来运行。根据实现的不同,Java 线程可以分为本机线程和 Java 堆栈。除了堆栈空间,每个线程还需要为线程本地存储(thread-local storage)和内部数据结构提供一些本机内存。尽管每个线程使用的内存量非常小,但对于拥有数百个线程的应用程序来说,线程堆栈的总内存使用量可能非常大。如果运行的应用程序的线程数量比可用于处理它们的处理器数量多,效率通常很低,并且可能导致糟糕的性能和更高的内存占用。
ii.本机内存耗尽:
Java运行时善于以不同的方式来处理Java堆空间的耗尽和本机堆空间的耗尽,但是这两种情形具有类似症状,当Java堆空间耗尽的时候,Java应用程序很难正常运行,因为Java应用程序必须通过分配对象来完成工作,只要Java堆被填满,就会出现糟糕的GC性能,并且抛出OutOfMemoryError。相反,一旦 Java 运行时开始运行并且应用程序处于稳定状态,它可以在本机堆完全耗尽之后继续正常运行,不一定会发生奇怪的行为,因为需要分配本机内存的操作比需要分配 Java 堆的操作少得多。尽管需要本机内存的操作因 JVM 实现不同而异,但也有一些操作很常见:启动线程、加载类以及执行某种类型的网络和文件 I/O。本机内存不足行为与 Java 堆内存不足行为也不太一样,因为无法对本机堆分配进行控制,尽管所有 Java 堆分配都在 Java 内存管理系统控制之下,但任何本机代码(无论其位于 JVM、Java 类库还是应用程序代码中)都可能执行本机内存分配,而且会失败。尝试进行分配的代码然后会处理这种情况,无论设计人员的意图是什么:它可能通过 JNI 接口抛出一个 OutOfMemoryError,在屏幕上输出一条消息,发生无提示失败并在稍后再试一次,或者执行其他操作。
iii.例子:
这篇文章一致都在讲概念,这里既然提到了ByteBuffer,先提供一个简单的例子演示该类的使用:
——[$]使用NIO读取txt文件——
package org.susan.java.io;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
public class ExplicitChannelRead {
public static void main(String args[]){
FileInputStream fileInputStream;
FileChannel fileChannel;
long fileSize;
ByteBuffer byteBuffer;
try{
fileInputStream = new FileInputStream("D://read.txt");
fileChannel = fileInputStream.getChannel();
fileSize = fileChannel.size();
byteBuffer = ByteBuffer.allocate((int)fileSize);
fileChannel.read(byteBuffer);
byteBuffer.rewind();
for( int i = 0; i < fileSize; i++ )
System.out.print((char)byteBuffer.get());
fileChannel.close();
fileInputStream.close();
}catch(IOException ex){
ex.printStackTrace();
}
}
}
在读取文件的路径放上该txt文件里面写入:Hello World,上边这段代码就是使用NIO的方式读取文件系统上的文件,这段程序的输入就为:
Hello World
——[$]获取ByteBuffer上的字节转换为Byte数组——
package org.susan.java.io;
import java.nio.ByteBuffer;
public class ByteBufferToByteArray {
public static void main(String args[]) throws Exception{
// 从byte数组创建ByteBuffer
byte[] bytes = new byte[10];
ByteBuffer buffer = ByteBuffer.wrap(bytes);
// 在position和limit,也就是ByteBuffer缓冲区的首尾之间读取字节
bytes = new byte[buffer.remaining()];
buffer.get(bytes, 0, bytes.length);
// 读取所有ByteBuffer内的字节
buffer.clear();
bytes = new byte[buffer.capacity()];
buffer.get(bytes, 0, bytes.length);
}
}
上边代码就是从ByteBuffer到byte数组的转换过程,有了这个过程在开发过程中可能更加方便,ByteBuffer的详细讲解我保留到IO部分,这里仅仅是涉及到了一些,所以提供两段实例代码。
iv.共享内存:
在Java语言里面,没有共享内存的概念,但是在某些引用中,共享内存却很受用,例如Java语言的分布式系统,存着大量的Java分布式共享对象,很多时候需要查询这些对象的状态,以查看系统是否运行正常或者了解这些对象目前的一些统计数据和状态。如果使用的是网络通信的方式,显然会增加应用的额外开销,也增加了不必要的应用编程,如果是共享内存方式,则可以直接通过共享内存查看到所需要的对象的数据和统计数据,从而减少一些不必要的麻烦。
1)共享内存特点:
- 可以被多个进程打开访问
- 读写操作的进程在执行读写操作的时候其他进程不能进行写操作
- 多个进程可以交替对某一个共享内存执行写操作
- 一个进程执行了内存写操作过后,不影响其他进程对该内存的访问,同时其他进程对更新后的内存具有可见性
- 在进程执行写操作时如果异常退出,对其他进程的写操作禁止自动解除
- 相对共享文件,数据访问的方便性和效率
2)出现情况:
- 独占的写操作,相应有独占的写操作等待队列。独占的写操作本身不会发生数据的一致性问题;
- 共享的写操作,相应有共享的写操作等待队列。共享的写操作则要注意防止发生数据的一致性问题;
- 独占的读操作,相应有共享的读操作等待队列;
- 共享的读操作,相应有共享的读操作等待队列;
3)Java*享内存的实现:
JDK 1.4里面的MappedByteBuffer为开发人员在Java中实现共享内存提供了良好的方法,该缓冲区实际上是一个磁盘文件的内存映象,二者的变化会保持同步,即内存数据发生变化过后会立即反应到磁盘文件中,这样会有效地保证共享内存的实现,将共享文件和磁盘文件简历联系的是文件通道类:FileChannel,该类的加入是JDK为了统一外围设备的访问方法,并且加强了多线程对同一文件进行存取的安全性,这里可以使用它来建立共享内存用,它建立了共享内存和磁盘文件之间的一个通道。打开一个文件可使用RandomAccessFile类的getChannel方法,该方法直接返回一个文件通道,该文件通道由于对应的文件设为随机存取,一方面可以进行读写两种操作,另外一个方面使用它不会破坏映象文件的内容。这里,如果使用FileOutputStream和FileInputStream则不能理想地实现共享内存的要求,因为这两个类同时实现*读写很困难。
下边代码段实现了上边提及的共享内存功能
// 获得一个只读的随机存取文件对象
RandomAccessFile RAFile = new RandomAccessFile(filename,"r");
// 获得相应的文件通道
FileChannel fc = RAFile.getChannel();
// 取得文件的实际大小
int size = (int)fc.size();
// 获得共享内存缓冲区,该共享内存只读
MappedByteBuffer mapBuf = fc.map(FileChannel.MAP_RO,0,size);
// 获得一个可读写的随机存取文件对象
RAFile = new RandomAccessFile(filename,"rw");
// 获得相应的文件通道
fc = RAFile.getChannel();
// 取得文件的实际大小,以便映像到共享内存
size = (int)fc.size();
// 获得共享内存缓冲区,该共享内存可读写
mapBuf = fc.map(FileChannel.MAP_RW,0,size);
// 获取头部消息:存取权限
mode = mapBuf.getInt();
如果多个应用映象使用同一文件名的共享内存,则意味着这多个应用共享了同一内存数据,这些应用对于文件可以具有同等存取权限,一个应用对数据的刷新会更新到多个应用中。为了防止多个应用同时对共享内存进行写操作,可以在该共享内存的头部信息加入写操作标记,该共享文件的头部基本信息至少有:
- 共享内存长度
- 共享内存目前的存取模式
共享文件的头部信息是私有信息,多个应用可以对同一个共享内存执行写操作,执行写操作和结束写操作的时候,可以使用如下方法:
public boolean startWrite()
{
if(mode == 0) // 这里mode代表共享内存的存取模式,为0代表可写
{
mode = 1; // 意味着别的应用不可写
mapBuf.flip();
mapBuf.putInt(mode); //写入共享内存的头部信息
return true;
}
else{
return false; //表明已经有应用在写该共享内存了,本应用不能够针对共享内存再做写操作
}
}
public boolean stopWrite()
{
mode = 0; // 释放写权限
mapBuf.flip();
mapBuf.putInt(mode); //写入共享内存头部信息
return true;
}
【*:上边提供了对共享内存执行写操作过程的两个方法,这两个方法其实理解起来很简单,真正需要思考的是一个针对存取模式的设置,其实这种机制和最前面提到的内存的锁模式有点类似,一旦当mode(存取模式)设置称为可写的时候,startWrite才能返回true,不仅仅如此,某个应用程序在向共享内存写入数据的时候还会修改其存取模式,因为如果不修改的话就会导致其他应用同样针对该内存是可写的,这样就使得共享内存的实现变得混乱,而在停止写操作stopWrite的时候,需要将mode设置称为1,也就是上边注释段提到的释放写权限。】
关于锁的知识这里简单做个补充【*:上边代码的这种模式可以理解为一种简单的锁模式】:一般情况下,计算机编程中会经常遇到锁模式,在整个锁模式过程中可以将锁分为两类(这里只是辅助理解,不是严格的锁分类)——共享锁和排他锁(也称为独占锁),锁的定位是定位于针对所有与计算机有关的资源比如内存、文件、存储空间等,针对这些资源都可能出现锁模式。在上边堆和栈一节讲到了Java对象锁,其实不仅仅是对象,只要是计算机中会出现写入和读取共同操作的资源,都有可能出现锁模式。
共享锁——当应用程序获得了资源的共享锁的时候,那么应用程序就可以直接访问该资源,资源的共享锁可以被多个应用程序拿到,在Java里面线程之间有时候也存在对象的共享锁,但是有一个很明显的特征,也就是内存共享锁只能读取数据,不能够写入数据,不论是什么资源,当应用程序仅仅只能拿到该资源的共享锁的时候,是不能够针对该资源进行写操作的。
独占锁——当应用程序获得了资源的独占锁的时候,应用程序访问该资源在共享锁上边多了一个权限就是写权限,针对资源本身而言,一个资源只有一把独占锁,也就是说一个资源只能同时被一个应用或者一个执行代码程序允许写操作,Java线程中的对象写操作也是这个道理,若某个应用拿到了独占锁的时候,不仅仅可以读取资源里面的数据,而且可以向该资源进行数据写操作。
数据一致性——当资源同时被应用进行读写访问的时候,有可能会出现数据一致性问题,比如A应用拿到了资源R1的独占锁,B应用拿到了资源R1的共享锁,A在针对R1进行写操作,而两个应用的操作——A的写操作和B的读操作出现了一个时间差,s1的时候B读取了R1的资源,s2的时候A写入了数据修改了R1的资源,s3的时候B又进行了第二次读,而两次读取相隔时间比较短暂而且初衷没有考虑到A在B的读取过程修改了资源,这种情况下针对锁模式就需要考虑到数据一致性问题。独占锁的排他性在这里的意思是该锁只能被一个应用获取,获取过程只能由这个应用写入数据到资源内部,除非它释放该锁,否则其他拿不到锁的应用是无法对资源进行写入操作的。
按照上边的思路去理解代码里面实现共享内存的过程就更加容易理解了。
如果执行写操作的应用异常中止,那么映像文件的共享内存将不再能执行写操作。为了在应用异常中止后,写操作禁止标志自动消除,必须让运行的应用获知退出的应用。在多线程应用中,可以用同步方法获得这样的效果,但是在多进程中,同步是不起作用的。方法可以采用的多种技巧,这里只是描述一可能的实现:采用文件锁的方式。写共享内存应用在获得对一个共享内存写权限的时候,除了判断头部信息的写权限标志外,还要判断一个临时的锁文件是否可以得到,如果可以得到,则即使头部信息的写权限标志为1(上述),也可以启动写权限,其实这已经表明写权限获得的应用已经异常退出,这段代码如下:
// 打开一个临时文件,注意统一共享内存,该文件名必须相同,可以在共享文件名后边添加“.lock”后缀
RandomAccessFile files = new RandomAccessFile("memory.lock","rw");
// 获取文件通道
FileChannel lockFileChannel = files.getChannel();
// 获取文件的独占锁,该方法不产生任何阻塞直接返回
FileLock fileLock = lockFileChannel.tryLock();
// 如果为空表示已经有应用占有了
if( fileLock == null ){
// ...不可写
}else{
// ...可以执行写操作
}
4)共享内存的应用:
在Java中,共享内存一般有两种应用:
[1]永久对象配置——在java服务器应用中,用户可能会在运行过程中配置一些参数,而这些参数需要永久 有效,当服务器应用重新启动后,这些配置参数仍然可以对应用起作用。这就可以用到该文 中的共享内存。该共享内存中保存了服务器的运行参数和一些对象运行特性。可以在应用启动时读入以启用以前配置的参数。
[2]查询共享数据——一个应用(例 sys.java)是系统的服务进程,其系统的运行状态记录在共享内存中,其中运行状态可能是不断变化的。为了随时了解系统的运行状态,启动另一个应用(例 mon.java),该应用查询该共享内存,汇报系统的运行状态。
v.小节:
提供本机内存以及共享内存的知识,主要是为了让读者能够更顺利地理解JVM内部内存模型的物理原理,包括JVM如何和操作系统在内存这个级别进行交互,理解了这些内容就让读者对Java内存模型的认识会更加深入,而且不容易遗忘。其实Java的内存模型远不及我们想象中那么简单,而且其结构极端复杂,看过《Inside JVM》的朋友应该就知道,结合JVM指令集去写点小代码测试.class文件的里层结构也不失为一种好玩的学习方法。
4.防止内存泄漏
Java中会有内存泄漏,听起来似乎是很不正常的,因为Java提供了垃圾回收器针对内存进行自动回收,但是Java还是会出现内存泄漏的。
i.什么是Java中的内存泄漏:
在Java语言中,内存泄漏就是存在一些被分配的对象,这些对象有两个特点:这些对象可达,即在对象内存的有向图中存在通路可以与其相连;其次,这些对象是无用的,即程序以后不会再使用这些对象了。如果对象满足这两个条件,该对象就可以判定为Java中的内存泄漏,这些对象不会被GC回收,然而它却占用内存,这就是Java语言中的内存泄漏。Java中的内存泄漏和C++中的内存泄漏还存在一定的区别,在C++里面,内存泄漏的范围更大一些,有些对象被分配了内存空间,但是却不可达,由于C++中没有GC,这些内存将会永远收不回来,在Java中这些不可达对象则是被GC负责回收的,因此程序员不需要考虑这一部分的内存泄漏。二者的图如下:
因此按照上边的分析,Java语言中也是存在内存泄漏的,但是其内存泄漏范围比C++要小很多,因为Java里面有个特殊程序回收所有的不可达对象:垃圾回收器。对于程序员来说,GC基本是透明的,不可见的。虽然,我们只有几个函数可以访问GC,例如运行GC的函数System.gc(),但是根据Java语言规范定义,该函数不保证JVM的垃圾收集器一定会执行。因为,不同的JVM实现者可能使用不同的算法管理GC。通常,GC的线程的优先级别较低,JVM调用GC的策略也有很多种,有的是内存使用到达一定程度时,GC才开始工作,也有定时执行的,有的是平缓执行GC,有的是中断式执行GC。但通常来说,我们不需要关心这些。除非在一些特定的场合,GC的执行影响应用程序的性能,例如对于基于Web的实时系统,如网络游戏等,用户不希望GC突然中断应用程序执行而进行垃圾回收,那么我们需要调整GC的参数,让GC能够通过平缓的方式释放内存,例如将垃圾回收分解为一系列的小步骤执行,Sun提供的HotSpot JVM就支持这一特性。
举个例子:
——[$]内存泄漏的例子——
package org.susan.java.collection;
import java.util.Vector;
public class VectorMemoryLeak {
public static void main(String args[]){
Vector<String> vector = new Vector<String>();
for( int i = 0; i < 1000; i++ ){
String tempString = new String();
vector.add(tempString);
tempString = null;
}
}
}
从上边这个例子可以看到,循环申请了String对象,并且将申请的对象放入了一个Vector中,如果仅仅是释放对象本身,因为Vector仍然引用了该对象,所以这个对象对CG来说是不可回收的,因此如果对象加入到Vector后,还必须从Vector删除才能够回收,最简单的方式是将Vector引用设置成null。实际上这些对象已经没有用了,但是还是被代码里面的引用引用到了,这种情况GC拿它就没有了任何办法,这样就可以导致了内存泄漏。
【*:Java语言因为提供了垃圾回收器,照理说是不会出现内存泄漏的,Java里面导致内存泄漏的主要原因就是,先前申请了内存空间而忘记了释放。如果程序中存在对无用对象的引用,这些对象就会驻留在内存中消耗内存,因为无法让GC判断这些对象是否可达。如果存在对象的引用,这个对象就被定义为“有效的活动状态”,同时不会被释放,要确定对象所占内存被回收,必须要确认该对象不再被使用。典型的做法就是把对象数据成员设置成为null或者中集合中移除,当局部变量不需要的情况则不需要显示声明为null。】
ii.常见的Java内存泄漏
1)全局集合:
在大型应用程序中存在各种各样的全局数据仓库是很普遍的,比如一个JNDI树或者一个Session table(会话表),在这些情况下,必须注意管理存储库的大小,必须有某种机制从存储库中移除不再需要的数据。
[$]解决:
[1]常用的解决方法是周期运作清除作业,该作业会验证仓库中的数据然后清楚一切不需要的数据
[2]另外一种方式是反向链接计数,集合负责统计集合中每个入口的反向链接数据,这要求反向链接告诉集合合适会退出入口,当反向链接数目为零的时候,该元素就可以移除了。
2)缓存:
缓存一种用来快速查找已经执行过的操作结果的数据结构。因此,如果一个操作执行需要比较多的资源并会多次被使用,通常做法是把常用的输入数据的操作结果进行缓存,以便在下次调用该操作时使用缓存的数据。缓存通常都是以动态方式实现的,如果缓存设置不正确而大量使用缓存的话则会出现内存溢出的后果,因此需要将所使用的内存容量与检索数据的速度加以平衡。
[$]解决:
[1]常用的解决途径是使用java.lang.ref.SoftReference类坚持将对象放入缓存,这个方法可以保证当虚拟机用完内存或者需要更多堆的时候,可以释放这些对象的引用。
3)类加载器:
Java类装载器的使用为内存泄漏提供了许多可乘之机。一般来说类装载器都具有复杂结构,因为类装载器不仅仅是只与"常规"对象引用有关,同时也和对象内部的引用有关。比如数据变量,方法和各种类。这意味着只要存在对数据变量,方法,各种类和对象的类装载器,那么类装载器将驻留在JVM中。既然类装载器可以同很多的类关联,同时也可以和静态数据变量关联,那么相当多的内存就可能发生泄漏。
iii.Java引用【摘录自前边的《Java引用总结》】:
Java中的对象引用主要有以下几种类型:
1)强可及对象(strongly reachable):
可以通过强引用访问的对象,一般来说,我们平时写代码的方式都是使用的强引用对象,比如下边的代码段:
StringBuilder builder= new StringBuilder();
上边代码部分引用obj这个引用将引用内存堆中的一个对象,这种情况下,只要obj的引用存在,垃圾回收器就永远不会释放该对象的存储空间。这种对象我们又成为强引用(Strong references),这种强引用方式就是Java语言的原生的Java引用,我们几乎每天编程的时候都用到。上边代码JVM存储了一个StringBuilder类型的对象的强引用在变量builder呢。强引用和GC的交互是这样的,如果一个对象通过强引用可达或者通过强引用链可达的话这种对象就成为强可及对象,这种情况下的对象垃圾回收器不予理睬。如果我们开发过程不需要垃圾回器回收该对象,就直接将该对象赋为强引用,也是普通的编程方法。
2)软可及对象(softly reachable):
不通过强引用访问的对象,即不是强可及对象,但是可以通过软引用访问的对象就成为软可及对象,软可及对象就需要使用类SoftReference(java.lang.ref.SoftReference)。此种类型的引用主要用于内存比较敏感的高速缓存,而且此种引用还是具有较强的引用功能,当内存不够的时候GC会回收这类内存,因此如果内存充足的时候,这种引用通常不会被回收的。不仅仅如此,这种引用对象在JVM里面保证在抛出OutOfMemory异常之前,设置成为null。通俗地讲,这种类型的引用保证在JVM内存不足的时候全部被清除,但是有个关键在于:垃圾收集器在运行时是否释放软可及对象是不确定的,而且使用垃圾回收算法并不能保证一次性寻找到所有的软可及对象。当垃圾回收器每次运行的时候都可以随意释放不是强可及对象占用的内存,如果垃圾回收器找到了软可及对象过后,可能会进行以下操作:
- 将SoftReference对象的referent域设置成为null,从而使该对象不再引用heap对象。
- SoftReference引用过的内存堆上的对象一律被生命为finalizable。
- 当内存堆上的对象finalize()方法被运行而且该对象占用的内存被释放,SoftReference对象就会被添加到它的ReferenceQueue,前提条件是ReferenceQueue本身是存在的。
既然Java里面存在这样的对象,那么我们在编写代码的时候如何创建这样的对象呢?创建步骤如下:
先创建一个对象,并使用普通引用方式【强引用】,然后再创建一个SoftReference来引用该对象,最后将普通引用设置为null,通过这样的方式,这个对象就仅仅保留了一个SoftReference引用,同时这种情况我们所创建的对象就是SoftReference对象。一般情况下,我们可以使用该引用来完成Cache功能,就是前边说的用于高速缓存,保证最大限度使用内存而不会引起内存泄漏的情况。下边的代码段:
public static void main(String args[])
{
//创建一个强可及对象
A a = new A();
//创建这个对象的软引用SoftReference
SoftReference sr = new SoftReference(a);
//将强引用设置为空,以遍垃圾回收器回收强引用
a = null;
//下次使用该对象的操作
if( sr != null ){
a = (A)sr.get();
}else{
//这种情况就是由于内存过低,已经将软引用释放了,因此需要重新装载一次
a = new A();
sr = new SoftReference(a);
}
}
软引用技术使得Java系统可以更好地管理内存,保持系统稳定,防止内存泄漏,避免系统崩溃,因此在处理一些内存占用大而且生命周期长使用不频繁的对象可以使用该技术。
3)弱可及对象(weakly reachable):
不是强可及对象同样也不是软可及对象,仅仅通过弱引用WeakReference(java.lang.ref.WeakReference)访问的对象,这种对象的用途在于规范化映射(canonicalized mapping),对于生存周期相对比较长而且重新创建的时候开销少的对象,弱引用也比较有用,和软引用对象不同的是,垃圾回收器如果碰到了弱可及对象,将释放WeakReference对象的内存,但是垃圾回收器需要运行很多次才能够找到弱可及对象。弱引用对象在使用的时候,可以配合ReferenceQueue类使用,如果弱引用被回收,JVM就会把这个弱引用加入到相关的引用队列中去。最简单的弱引用方法如以下代码:
WeakReference weakWidget = new WeakReference(classA);
在上边代码里面,当我们使用weakWidget.get()来获取classA的时候,由于弱引用本身是无法阻止垃圾回收的,所以我们也许会拿到一个null为返回。【*:这里提供一个小技巧,如果我们希望取得某个对象的信息,但是又不影响该对象的垃圾回收过程,我们就可以使用WeakReference来记住该对象,一般我们在开发调试器和优化器的时候使用这个是很好的一个手段。】
如果上边的代码部分,我们通过weakWidget.get()返回的是null就证明该对象已经被垃圾回收器回收了,而这种情况下弱引用对象就失去了使用价值,GC就会定义为需要进行清除工作。这种情况下弱引用无法引用任何对象,所以在JVM里面就成为了一个死引用,这就是为什么我们有时候需要通过ReferenceQueue类来配合使用的原因,使用了ReferenceQueue过后,就使得我们更加容易监视该引用的对象,如果我们通过一ReferenceQueue类来构造一个弱引用,当弱引用的对象已经被回收的时候,系统将自动使用对象引用队列来代替对象引用,而且我们可以通过ReferenceQueue类的运行来决定是否真正要从垃圾回收器里面将该死引用(Dead Reference)清除。
弱引用代码段:
//创建普通引用对象
MyObject object = new MyObject();
//创建一个引用队列
ReferenceQueue rq = new ReferenceQueue();
//使用引用队列创建MyObject的弱引用
WeakReference wr = new WeakReference(object,rq);
这里提供两个实在的场景来描述弱引用的相关用法:
[1]你想给对象附加一些信息,于是你用一个 Hashtable 把对象和附加信息关联起来。你不停的把对象和附加信息放入 Hashtable 中,但是当对象用完的时候,你不得不把对象再从 Hashtable 中移除,否则它占用的内存变不会释放。万一你忘记了,那么没有从 Hashtable 中移除的对象也可以算作是内存泄漏。理想的状况应该是当对象用完时,Hashtable 中的对象会自动被垃圾收集器回收,不然你就是在做垃圾回收的工作。
[2]你想实现一个图片缓存,因为加载图片的开销比较大。你将图片对象的引用放入这个缓存,以便以后能够重新使用这个对象。但是你必须决定缓存中的哪些图片不再需要了,从而将引用从缓存中移除。不管你使用什么管理缓存的算法,你实际上都在处理垃圾收集的工作,更简单的办法(除非你有特殊的需求,这也应该是最好的办法)是让垃圾收集器来处理,由它来决定回收哪个对象。
当Java回收器遇到了弱引用的时候有可能会执行以下操作:
- 将WeakReference对象的referent域设置成为null,从而使该对象不再引用heap对象。
- WeakReference引用过的内存堆上的对象一律被生命为finalizable。
- 当内存堆上的对象finalize()方法被运行而且该对象占用的内存被释放,WeakReference对象就会被添加到它的ReferenceQueue,前提条件是ReferenceQueue本身是存在的。
4)清除:
当引用对象的referent域设置为null,并且引用类在内存堆中引用的对象声明为可结束的时候,该对象就可以清除,清除不做过多的讲述
5)虚可及对象(phantomly reachable):
不是强可及对象,也不是软可及对象,同样不是弱可及对象,之所以把虚可及对象放到最后来讲,主要也是因为它的特殊性,有时候我们又称之为“幽灵对象”,已经结束的,可以通过虚引用来访问该对象。我们使用类PhantomReference(java.lang.ref.PhantomReference)来访问,这个类只能用于跟踪被引用对象进行的收集,同样的,可以用于执行per-mortern清除操作。PhantomReference必须与ReferenceQueue类一起使用。需要使用ReferenceQueue是因为它能够充当通知机制,当垃圾收集器确定了某个对象是虚可及对象的时候,PhantomReference对象就被放在了它的ReferenceQueue上,这就是一个通知,表明PhantomReference引用的对象已经结束,可以收集了,一般情况下我们刚好在对象内存在回收之前采取该行为。这种引用不同于弱引用和软引用,这种方式通过get()获取到的对象总是返回null,仅仅当这些对象在ReferenceQueue队列里面的时候,我们可以知道它所引用的哪些对对象是死引用(Dead Reference)。而这种引用和弱引用的区别在于:
弱引用(WeakReference)是在对象不可达的时候尽快进入ReferenceQueue队列的,在finalization方法执行和垃圾回收之前是确实会发生的,理论上这类对象是不正确的对象,但是WeakReference对象可以继续保持Dead状态,
虚引用(PhantomReference)是在对象确实已经从物理内存中移除过后才进入的ReferenceQueue队列,而且get()方法会一直返回null
当垃圾回收器遇到了虚引用的时候将有可能执行以下操作:
- PhantomReference引用过的heap对象声明为finalizable;
- 虚引用在堆对象释放之前就添加到了它的ReferenceQueue里面,这种情况使得我们可以在堆对象被回收之前采取操作【*:再次提醒,PhantomReference对象必须经过关联的ReferenceQueue来创建,就是说必须和ReferenceQueue类配合操作】
看似没有用处的虚引用,有什么用途呢?
- 首先,我们可以通过虚引用知道对象究竟什么时候真正从内存里面移除的,而且这也是唯一的途径。
- 虚引用避过了finalize()方法,因为对于此方法的执行而言,虚引用真正引用到的对象是异常对象,若在该方法内要使用对象只能重建。一般情况垃圾回收器会轮询两次,一次标记为finalization,第二次进行真实的回收,而往往标记工作不能实时进行,或者垃圾回收其会等待一个对象去标记finalization。这种情况很有可能引起MemoryOut,而使用虚引用这种情况就会完全避免。因为虚引用在引用对象的过程不会去使得这个对象由Dead复活,而且这种对象是可以在回收周期进行回收的。
在JVM内部,虚引用比起使用finalize()方法更加安全一点而且更加有效。而finaliaze()方法回收在虚拟机里面实现起来相对简单,而且也可以处理大部分工作,所以我们仍然使用这种方式来进行对象回收的扫尾操作,但是有了虚引用过后我们可以选择是否手动操作该对象使得程序更加高效完美。
iv.防止内存泄漏[来自IBM开发中心]:
1)使用软引用阻止泄漏:
[1]在Java语言中有一种形式的内存泄漏称为对象游离(Object Loitering):
——[$]对象游离——
// 注意,这段代码属于概念说明代码,实际应用中不要模仿
public class LeakyChecksum{
private byte[] byteArray;
public synchronized int getFileCheckSum(String filename)
{
int len = getFileSize(filename);
if( byteArray == null || byteArray.length < len )
byteArray = new byte[len];
readFileContents(filename,byteArray);
// 计算该文件的值然后返回该对象
}
}
上边的代码是类LeakyChecksum用来说明对象游离的概念,里面有一个getFileChecksum()方法用来计算文件内容校验和,getFileCheckSum方法将文件内容读取到缓冲区中计算校验和,更加直观的实现就是简单地将缓冲区作为getFileChecksum中的本地变量分配,但是上边这个版本比这种版本更加“聪明”,不是将缓冲区缓冲在实例中字段中减少内存churn。该“优化”通常不带来预期的好处,对象分配比很多人期望的更加便宜。(还要注意,将缓冲区从本地变量提升到实例变量,使得类若不带有附加的同步,就不再是线程安全的了。直观的实现不需要将 getFileChecksum() 声明为 synchronized,并且会在同时调用时提供更好的可伸缩性。)
这个类存在很多的问题,但是我们着重来看内存泄漏。缓存缓冲区的决定很可能是根据这样的假设得出的,即该类将在一个程序中被调用许多次,因此它应该更加有效,以重用缓冲区而不是重新分配它。但是结果是,缓冲区永远不会被释放,因为它对程序来说总是可及的(除非LeakyChecksum对象被垃圾收集了)。更坏的是,它可以增长,却不可以缩小,所以 LeakyChecksum 将永久保持一个与所处理的最大文件一样大小的缓冲区。退一万步说,这也会给垃圾收集器带来压力,并且要求更频繁的收集;为计算未来的校验和而保持一个大型缓冲区并不是可用内存的最有效利用。LeakyChecksum 中问题的原因是,缓冲区对于 getFileChecksum() 操作来说逻辑上是本地的,但是它的生命周期已经被人为延长了,因为将它提升到了实例字段。因此,该类必须自己管理缓冲区的生命周期,而不是让 JVM 来管理。
这里可以提供一种策略就是使用Java里面的软引用:
弱引用如何可以给应用程序提供当对象被程序使用时另一种到达该对象的方法,但是不会延长对象的生命周期。Reference 的另一个子类——软引用——可满足一个不同却相关的目的。其中弱引用允许应用程序创建不妨碍垃圾收集的引用,软引用允许应用程序通过将一些对象指定为 “expendable” 而利用垃圾收集器的帮助。尽管垃圾收集器在找出哪些内存在由应用程序使用哪些没在使用方面做得很好,但是确定可用内存的最适当使用还是取决于应用程序。如果应用程序做出了不好的决定,使得对象被保持,那么性能会受到影响,因为垃圾收集器必须更加辛勤地工作,以防止应用程序消耗掉所有内存。高速缓存是一种常见的性能优化,允许应用程序重用以前的计算结果,而不是重新进行计算。高速缓存是 CPU 利用和内存使用之间的一种折衷,这种折衷理想的平衡状态取决于有多少内存可用。若高速缓存太少,则所要求的性能优势无法达到;若太多,则性能会受到影响,因为太多的内存被用于高速缓存上,导致其他用途没有足够的可用内存。因为垃圾收集器比应用程序更适合决定内存需求,所以应该利用垃圾收集器在做这些决定方面的帮助,这就是件引用所要做的。如果一个对象惟一剩下的引用是弱引用或软引用,那么该对象是软可及的(softly reachable)。垃圾收集器并不像其收集弱可及的对象一样尽量地收集软可及的对象,相反,它只在真正 “需要” 内存时才收集软可及的对象。软引用对于垃圾收集器来说是这样一种方式,即 “只要内存不太紧张,我就会保留该对象。但是如果内存变得真正紧张了,我就会去收集并处理这个对象。” 垃圾收集器在可以抛出OutOfMemoryError 之前需要清除所有的软引用。通过使用一个软引用来管理高速缓存的缓冲区,可以解决 LeakyChecksum中的问题,如上边代码所示。现在,只要不是特别需要内存,缓冲区就会被保留,但是在需要时,也可被垃圾收集器回收:
——[$]使用软引用修复上边代码段——
public class CachingChecksum
{
private SoftReference<byte[]> bufferRef;
public synchronized int getFileChecksum(String filename)
{
int len = getFileSize(filename);
byte[] byteArray = bufferRef.get();
if( byteArray == null || byteArray.length < len )
{
byteArray = new byte[len];
bufferRef.set(byteArray);
}
readFileContents(filename,byteArray);
}
}
一种廉价缓存:
CachingChecksum使用一个软引用来缓存单个对象,并让 JVM 处理从缓存中取走对象时的细节。类似地,软引用也经常用于 GUI 应用程序中,用于缓存位图图形。是否可使用软引用的关键在于,应用程序是否可从大量缓存的数据恢复。如果需要缓存不止一个对象,您可以使用一个 Map,但是可以选择如何使用软引用。您可以将缓存作为 Map<K, SoftReference<V>> 或SoftReference<Map<K,V>> 管理。后一种选项通常更好一些,因为它给垃圾收集器带来的工作更少,并且允许在特别需要内存时以较少的工作回收整个缓存。弱引用有时会错误地用于取代软引用,用于构建缓存,但是这会导致差的缓存性能。在实践中,弱引用将在对象变得弱可及之后被很快地清除掉——通常是在缓存的对象再次用到之前——因为小的垃圾收集运行得很频繁。对于在性能上非常依赖高速缓存的应用程序来说,软引用是一个不管用的手段,它确实不能取代能够提供灵活终止期、复制和事务型高速缓存的复杂的高速缓存框架。但是作为一种 “廉价(cheap and dirty)” 的高速缓存机制,它对于降低价格是很有吸引力的。正如弱引用一样,软引用也可创建为具有一个相关的引用队列,引用在被垃圾收集器清除时进入队列。引用队列对于软引用来说,没有对弱引用那么有用,但是它们可以用于发出管理警报,说明应用程序开始缺少内存。
2)垃圾回收对引用的处理:
弱引用和软引用都扩展了抽象的 Reference 类虚引用(phantom references),引用对象被垃圾收集器特殊地看待。垃圾收集器在跟踪堆期间遇到一个 Reference 时,不会标记或跟踪该引用对象,而是在已知活跃的 Reference 对象的队列上放置一个 Reference。在跟踪之后,垃圾收集器就识别软可及的对象——这些对象上除了软引用外,没有任何强引用。垃圾收集器然后根据当前收集所回收的内存总量和其他策略考虑因素,判断软引用此时是否需要被清除。将被清除的软引用如果具有相应的引用队列,就会进入队列。其余的软可及对象(没有清除的对象)然后被看作一个根集(root set),堆跟踪继续使用这些新的根,以便通过活跃的软引用而可及的对象能够被标记。处理软引用之后,弱可及对象的集合被识别 —— 这样的对象上不存在强引用或软引用。这些对象被清除和加入队列。所有 Reference 类型在加入队列之前被清除,所以处理事后检查(post-mortem)清除的线程永远不会具有 referent 对象的访问权,而只具有Reference 对象的访问权。因此,当 References 与引用队列一起使用时,通常需要细分适当的引用类型,并将它直接用于您的设计中(与 WeakHashMap 一样,它的 Map.Entry 扩展了 WeakReference)或者存储对需要清除的实体的引用。
3)使用弱引用堵住内存泄漏:
[1]全局Map造成的内存泄漏:
无意识对象保留最常见的原因是使用 Map 将元数据与临时对象(transient object)相关联。假定一个对象具有中等生命周期,比分配它的那个方法调用的生命周期长,但是比应用程序的生命周期短,如客户机的套接字连接。需要将一些元数据与这个套接字关联,如生成连接的用户的标识。在创建 Socket 时是不知道这些信息的,并且不能将数据添加到 Socket 对象上,因为不能控制 Socket 类或者它的子类。这时,典型的方法就是在一个全局 Map 中存储这些信息:
public class SocketManager{
private Map<Socket,User> m = new HashMap<Socket,User>();
public void setUser(Socket s,User u)
{
m.put(s,u);
}
public User getUser(Socket s){
return m.get(s);
}
public void removeUser(Socket s){
m.remove(s);
}
}
SocketManager socketManager;
//...
socketManager.setUser(socket,user);
这种方法的问题是元数据的生命周期需要与套接字的生命周期挂钩,但是除非准确地知道什么时候程序不再需要这个套接字,并记住从 Map 中删除相应的映射,否则,Socket 和 User 对象将会永远留在 Map 中,远远超过响应了请求和关闭套接字的时间。这会阻止 Socket 和User 对象被垃圾收集,即使应用程序不会再使用它们。这些对象留下来不受控制,很容易造成程序在长时间运行后内存爆满。除了最简单的情况,在几乎所有情况下找出什么时候 Socket 不再被程序使用是一件很烦人和容易出错的任务,需要人工对内存进行管理。
[2]弱引用内存泄漏代码:
程序有内存泄漏的第一个迹象通常是它抛出一个 OutOfMemoryError,或者因为频繁的垃圾收集而表现出糟糕的性能。幸运的是,垃圾收集可以提供能够用来诊断内存泄漏的大量信息。如果以 -verbose:gc 或者 -Xloggc 选项调用 JVM,那么每次 GC 运行时在控制台上或者日志文件中会打印出一个诊断信息,包括它所花费的时间、当前堆使用情况以及恢复了多少内存。记录 GC 使用情况并不具有干扰性,因此如果需要分析内存问题或者调优垃圾收集器,在生产环境中默认启用 GC 日志是值得的。有工具可以利用 GC 日志输出并以图形方式将它显示出来,JTune 就是这样的一种工具。观察 GC 之后堆大小的图,可以看到程序内存使用的趋势。对于大多数程序来说,可以将内存使用分为两部分:baseline 使用和 current load 使用。对于服务器应用程序,baseline 使用就是应用程序在没有任何负荷、但是已经准备好接受请求时的内存使用,current load 使用是在处理请求过程中使用的、但是在请求处理完成后会释放的内存。只要负荷大体上是恒定的,应用程序通常会很快达到一个稳定的内存使用水平。如果在应用程序已经完成了其初始化并且负荷没有增加的情况下,内存使用持续增加,那么程序就可能在处理前面的请求时保留了生成的对象。
public class MapLeaker{
public ExecuteService exec = Executors.newFixedThreadPool(5);
public Map<Task,TaskStatus> taskStatus
= Collections.synchronizedMap(new HashMap<Task,TaskStatus>());
private Random random = new Random();
private enum TaskStatus { NOT_STARTED, STARTED, FINISHED };
private class Task implements Runnable{
private int[] numbers = new int[random.nextInt(200)];
public void run()
{
int[] temp = new int[random.nextInt(10000)];
taskStatus.put(this,TaskStatus.STARTED);
doSomework();
taskStatus.put(this,TaskStatus.FINISHED);
}
}
public Task newTask()
{
Task t = new Task();
taskStatus.put(t,TaskStatus.NOT_STARTED);
exec.execute(t);
return t;
}
}
[3]使用弱引用堵住内存泄漏:
SocketManager 的问题是 Socket-User 映射的生命周期应当与 Socket 的生命周期相匹配,但是语言没有提供任何容易的方法实施这项规则。这使得程序不得不使用人工内存管理的老技术。幸运的是,从 JDK 1.2 开始,垃圾收集器提供了一种声明这种对象生命周期依赖性的方法,这样垃圾收集器就可以帮助我们防止这种内存泄漏——利用弱引用。弱引用是对一个对象(称为 referent)的引用的持有者。使用弱引用后,可以维持对 referent 的引用,而不会阻止它被垃圾收集。当垃圾收集器跟踪堆的时候,如果对一个对象的引用只有弱引用,那么这个 referent 就会成为垃圾收集的候选对象,就像没有任何剩余的引用一样,而且所有剩余的弱引用都被清除。(只有弱引用的对象称为弱可及(weakly reachable))WeakReference 的 referent 是在构造时设置的,在没有被清除之前,可以用 get() 获取它的值。如果弱引用被清除了(不管是 referent 已经被垃圾收集了,还是有人调用了 WeakReference.clear()),get() 会返回 null。相应地,在使用其结果之前,应当总是检查get() 是否返回一个非 null 值,因为 referent 最终总是会被垃圾收集的。用一个普通的(强)引用拷贝一个对象引用时,限制 referent 的生命周期至少与被拷贝的引用的生命周期一样长。如果不小心,那么它可能就与程序的生命周期一样——如果将一个对象放入一个全局集合中的话。另一方面,在创建对一个对象的弱引用时,完全没有扩展 referent 的生命周期,只是在对象仍然存活的时候,保持另一种到达它的方法。弱引用对于构造弱集合最有用,如那些在应用程序的其余部分使用对象期间存储关于这些对象的元数据的集合——这就是 SocketManager 类所要做的工作。因为这是弱引用最常见的用法,WeakHashMap 也被添加到 JDK 1.2 的类库中,它对键(而不是对值)使用弱引用。如果在一个普通 HashMap 中用一个对象作为键,那么这个对象在映射从 Map 中删除之前不能被回收,WeakHashMap 使您可以用一个对象作为 Map 键,同时不会阻止这个对象被垃圾收集。下边的代码给出了 WeakHashMap 的 get() 方法的一种可能实现,它展示了弱引用的使用:
public class WeakHashMap<K,V> implements Map<K,V>
{
private static class Entry<K,V> extends WeakReference<K> implements Map.Entry<K,V>
{
private V value;
private final int hash;
private Entry<K,V> next;
// ...
}
public V get(Object key)
{
int hash = getHash(key);
Entry<K,V> e = getChain(hash);
while(e != null)
{
k eKey = e.get();
if( e.hash == hash && (key == eKey || key.equals(eKey)))
return e.value;
e = e.next;
}
return null;
}
}
调用 WeakReference.get() 时,它返回一个对 referent 的强引用(如果它仍然存活的话),因此不需要担心映射在 while 循环体中消失,因为强引用会防止它被垃圾收集。WeakHashMap 的实现展示了弱引用的一种常见用法——一些内部对象扩展 WeakReference。其原因在下面一节讨论引用队列时会得到解释。在向 WeakHashMap 中添加映射时,请记住映射可能会在以后“脱离”,因为键被垃圾收集了。在这种情况下,get() 返回 null,这使得测试 get() 的返回值是否为 null 变得比平时更重要了。
[4]使用WeakHashMap堵住泄漏
在 SocketManager 中防止泄漏很容易,只要用 WeakHashMap 代替 HashMap 就行了,如下边代码所示。(如果 SocketManager 需要线程安全,那么可以用 Collections.synchronizedMap() 包装 WeakHashMap)。当映射的生命周期必须与键的生命周期联系在一起时,可以使用这种方法。不过,应当小心不滥用这种技术,大多数时候还是应当使用普通的 HashMap 作为 Map 的实现。
public class SocketManager{
private Map<Socket,User> m = new WeakHashMap<Socket,User>();
public void setUser(Socket s, User s)
{
m.put(s,u);
}
public User getUser(Socket s)
{
return m.get(s);
}
}
引用队列:
WeakHashMap 用弱引用承载映射键,这使得应用程序不再使用键对象时它们可以被垃圾收集,get() 实现可以根据 WeakReference.get() 是否返回 null 来区分死的映射和活的映射。但是这只是防止 Map 的内存消耗在应用程序的生命周期中不断增加所需要做的工作的一半,还需要做一些工作以便在键对象被收集后从 Map 中删除死项。否则,Map 会充满对应于死键的项。虽然这对于应用程序是不可见的,但是它仍然会造成应用程序耗尽内存,因为即使键被收集了,Map.Entry 和值对象也不会被收集。可以通过周期性地扫描 Map,对每一个弱引用调用 get(),并在 get() 返回 null 时删除那个映射而消除死映射。但是如果 Map 有许多活的项,那么这种方法的效率很低。如果有一种方法可以在弱引用的 referent 被垃圾收集时发出通知就好了,这就是引用队列的作用。引用队列是垃圾收集器向应用程序返回关于对象生命周期的信息的主要方法。弱引用有两个构造函数:一个只取 referent 作为参数,另一个还取引用队列作为参数。如果用关联的引用队列创建弱引用,在 referent 成为 GC 候选对象时,这个引用对象(不是referent)就在引用清除后加入 到引用队列中。之后,应用程序从引用队列提取引用并了解到它的 referent 已被收集,因此可以进行相应的清理活动,如去掉已不在弱集合中的对象的项。(引用队列提供了与 BlockingQueue 同样的出列模式 ——polled、timed blocking 和 untimed blocking。)WeakHashMap 有一个名为 expungeStaleEntries() 的私有方法,大多数 Map 操作中会调用它,它去掉引用队列中所有失效的引用,并删除关联的映射。
4)关于Java中引用思考:
先观察一个列表:
级别 | 回收时间 | 用途 | 生存时间 |
强引用 | 从来不会被回收 | 对象的一般状态 | JVM停止运行时终止 |
软引用 | 在内存不足时 | 在客户端移除对象引用过后,除非再次激活,否则就放在内存敏感的缓存中 | 内存不足时终止 |
弱引用 | 在垃圾回收时,也就是客户端已经移除了强引用,但是这种情况下内存还是客户端引用可达的 | 阻止自动删除不需要用的对象 | GC运行后终止 |
虚引用[幽灵引用] | 对象死亡之前,就是进行finalize()方法调用附近 | 特殊的清除过程 | 不定,当finalize()函数运行过后再回收,有可能之前就已经被回收了。 |
可以这样理解:
SoftReference:假定垃圾回收器确定在某一时间点某个对象是软可到达对象。这时,它可以选择自动清除针对该对象的所有软引用,以及通过强引用链,从其可以到达该对象的针对任何其他软可到达对象的所有软引用。在同一时间或晚些时候,它会将那些已经向引用队列注册的新清除的软引用加入队列。 软可到达对象的所有软引用都要保证在虚拟机抛出 OutOfMemoryError 之前已经被清除。否则,清除软引用的时间或者清除不同对象的一组此类引用的顺序将不受任何约束。然而,虚拟机实现不鼓励清除最近访问或使用过的软引用。 此类的直接实例可用于实现简单缓存;该类或其派生的子类还可用于更大型的数据结构,以实现更复杂的缓存。只要软引用的指示对象是强可到达对象,即正在实际使用的对象,就不会清除软引用。例如,通过保持最近使用的项的强指示对象,并由垃圾回收器决定是否放弃剩余的项,复杂的缓存可以防止放弃最近使用的项。一般来说,WeakReference我们用来防止内存泄漏,保证内存对象被VM回收。
WeakReference:弱引用对象,它们并不禁止其指示对象变得可终结,并被终结,然后被回收。弱引用最常用于实现规范化的映射。假定垃圾回收器确定在某一时间点上某个对象是弱可到达对象。这时,它将自动清除针对此对象的所有弱引用,以及通过强引用链和软引用,可以从其到达该对象的针对任何其他弱可到达对象的所有弱引用。同时它将声明所有以前的弱可到达对象为可终结的。在同一时间或晚些时候,它将那些已经向引用队列注册的新清除的弱引用加入队列。 SoftReference多用作来实现cache机制,保证cache的有效性。
PhantomReference:虚引用对象,在回收器确定其指示对象可另外回收之后,被加入队列。虚引用最常见的用法是以某种可能比使用 Java 终结机制更灵活的方式来指派 pre-mortem 清除操作。如果垃圾回收器确定在某一特定时间点上虚引用的指示对象是虚可到达对象,那么在那时或者在以后的某一时间,它会将该引用加入队列。为了确保可回收的对象仍然保持原状,虚引用的指示对象不能被检索:虚引用的 get 方法总是返回 null。与软引用和弱引用不同,虚引用在加入队列时并没有通过垃圾回收器自动清除。通过虚引用可到达的对象将仍然保持原状,直到所有这类引用都被清除,或者它们都变得不可到达。
以下是不确定概念
【*:Java引用的深入部分一直都是讨论得比较多的话题,上边大部分为摘录整理,这里再谈谈我个人的一些看法。从整个JVM框架结构来看,Java的引用和垃圾回收器形成了针对Java内存堆的一个对象的“闭包管理集”,其中在基本代码里面常用的就是强引用,强引用主要使用目的是就是编程的正常逻辑,这是所有的开发人员最容易理解的,而弱引用和软引用的作用是比较耐人寻味的。按照引用强弱,其排序可以为:强引用——软引用——弱引用——虚引用,为什么这样写呢,实际上针对垃圾回收器而言,强引用是它绝对不会随便去动的区域,因为在内存堆里面的对象,只有当前对象不是强引用的时候,该对象才会进入垃圾回收器的目标区域。
软引用又可以理解为“内存应急引用”,也就是说它和GC是完整地配合操作的,为了防止内存泄漏,当GC在回收过程出现内存不足的时候,软引用会被优先回收,从垃圾回收算法上讲,软引用在设计的时候是很容易被垃圾回收器发现的。为什么软引用是处理告诉缓存的优先选择的,主要有两个原因:第一,它对内存非常敏感,从抽象意义上讲,我们甚至可以任何它和内存的变化紧紧绑定到一起操作的,因为内存一旦不足的时候,它会优先向垃圾回收器报警以提示内存不足;第二,它会尽量保证系统在OutOfMemoryError之前将对象直接设置成为不可达,以保证不会出现内存溢出的情况;所以使用软引用来处理Java引用里面的高速缓存是很不错的选择。其实软引用不仅仅和内存敏感,实际上和垃圾回收器的交互也是敏感的,这点可以这样理解,因为当内存不足的时候,软引用会报警,而这种报警会提示垃圾回收器针对目前的一些内存进行清除操作,而在有软引用存在的内存堆里面,垃圾回收器会第一时间反应,否则就会MemoryOut了。按照我们正常的思维来考虑,垃圾回收器针对我们调用System.gc()的时候,是不会轻易理睬的,因为仅仅是收到了来自强引用层代码的请求,至于它是否回收还得看JVM内部环境的条件是否满足,但是如果是软引用的方式去申请垃圾回收器会优先反应,只是我们在开发过程不能控制软引用对垃圾回收器发送垃圾回收申请,而JVM规范里面也指出了软引用不会轻易发送申请到垃圾回收器。这里还需要解释的一点的是软引用发送申请不是说软引用像我们调用System.gc()这样直接申请垃圾回收,而是说软引用会设置对象引用为null,而垃圾回收器针对该引用的这种做法也会优先响应,我们可以理解为是软引用对象在向垃圾回收器发送申请。反应快并不代表垃圾回收器会实时反应,还是会在寻找软引用引用到的对象的时候遵循一定的回收规则,反应快在这里的解释是相对强引用设置对象为null,当软引用设置对象为null的时候,该对象的被收集的优先级比较高。
弱引用是一种比软引用相对复杂的引用,其实弱引用和软引用都是Java程序可以控制的,也就是说可以通过代码直接使得引用针对弱可及对象以及软可及对象是可引用的,软引用和弱引用引用的对象实际上通过一定的代码操作是可重新激活的,只是一般不会做这样的操作,这样的用法违背了最初的设计。弱引用和软引用在垃圾回收器的目标范围有一点点不同的就是,使用垃圾回收算法是很难找到弱引用的,也就是说弱引用用来监控垃圾回收的整个流程也是一种很好的选择,它不会影响垃圾回收的正常流程,这样就可以规范化整个对象从设置为null了过后的一个生命周期的代码监控。而且因为弱引用是否存在对垃圾回收整个流程都不会造成影响,可以这样认为,垃圾回收器找得到弱引用,该引用的对象就会被回收,如果找不到弱引用,一旦等到GC完成了垃圾回收过后,弱引用引用的对象占用的内存也会自动释放,这就是软引用在垃圾回收过后的自动终止。
最后谈谈虚引用,虚引用应该是JVM里面最厉害的一种引用,它的厉害在于它可以在对象的内存从物理内存中清除掉了过后再引用该对象,也就是说当虚引用引用到对象的时候,这个对象实际已经从物理内存堆中清除掉了,如果我们不用手动对对象死亡或者濒临死亡进行处理的话,JVM会默认调用finalize函数,但是虚引用存在于该函数附近的生命周期内,所以可以手动对对象的这个范围的周期进行监控。它之所以称为“幽灵引用”就是因为该对象的物理内存已经不存在的,我个人觉得JVM保存了一个对象状态的镜像索引,而这个镜像索引里面包含了对象在这个生命周期需要的所有内容,这里的所需要就是这个生命周期内需要的对象数据内容,也就是对象死亡和濒临死亡之前finalize函数附近,至于强引用所需要的其他对象附加内容是不需要在这个镜像里面包含的,所以即使物理内存不存在,还是可以通过虚引用监控到该对象的,只是这种情况是否可以让对象重新激活为强引用我就不敢说了。因为虚引用在引用对象的过程不会去使得这个对象由Dead复活,而且这种对象是可以在回收周期进行回收的。】
5.总结:
本章节主要涵盖了Java里面比较底层的一个章节,主要是以JVM内存模型为基础包括JVM针对内存的线程模型的探讨以及针对Java里面内存堆和栈的详细分析。特别感谢白远方同学提供的汇编方面关于操作系统以及内存发展的资料提供。
参考:IBM开发中心文档,《Inside JVM》
本文的讲解可能比较概念化,希望所有读者能够仔细品味,Java与对象相关的底层内从这里面都提及到了,主要是方便初学者和深入者能够更加理解Java虚拟机处理Java对象的一个流程以及底层的相关原理,也方便查询和参考,可能会有不完善的地方,如果有什么概念错误,请来Email告知,谢谢:silentbalanceyh@126.com。而且因为这一个章节的内容概念很多,整理思考和撰写花了太长时间,抱歉!
============== End