与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。而且因为这一个章节的内容概念很多,整理思考和撰写花了太长时间,抱歉!