Java内存区域

JVM的主要组成部分及作用

在这里插入图片描述
JVM主要由四个部分组成:

1. 类加载器(ClassLoader)
2. 运行时数据区(Runtime Data Area)
3. 执行引擎(Execution Engine)
4. 本地库接口(Native Interface)

各组件的作用:首先通过编译器把 Java 代码转换成字节码,类加载器(ClassLoader)再把字节码加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行,因此需要特定的命令解析器执行引擎(Execution Engine),将字节码翻译成底层系统指令,再交由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能。

Java程序运行机制步骤

在这里插入图片描述

  • 首先利用IDE集成开发工具编写Java源代码,源文件的后缀名为.java
  • 编译器(javac)将源代码编译成字节码文件,后缀名.class
  • 解释器(java命令)运行字节码

类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class对象,用来封装类在方法区内的数据结构。

JVM运行时数据区

在这里插入图片描述

  • 程序计数器字节码解释器工作就是通过改变这个计数器的值来选取下一条需要执行指令的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖计数器完成;
  • Java虚拟机栈:生命周期和线程一致,每个方法在执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息,每一个方法从调用直到执行结束,就对应着一个栈帧从虚拟机栈中入栈到出栈的过程;
  • 本地方法栈:与Java虚拟机栈不同的是,Java虚拟机栈是为执行Java方法(字节码)服务,而本地方法栈是为虚拟机使用到的本地方法服务。
    Java虚拟机栈和本地方法栈都有可能抛出StackOverFlowError【线程请求的栈深度大于虚拟机所允许的深度】和OutOfMemoryError【如果虚拟机可以动态扩展,而扩展时无法申请到足够的内存】。
  • Java堆:主要用来存放对象实例和数组,垃圾回收的主要区域;
  • 方法区:用来存放已被加载的类信息常量静态变量即时编译器编译后的代码等数据;
  • 运行时常量池:方法区的一部分,编译器生成的字面量符号引用会在类加载后放入这个区域。

堆栈的区别

  • 可见度:堆线程共享,栈线程私有;
  • 存储内容:堆中主要存放对象实例,数组,栈中主要存放基本数据类型,对象的引用;
  • 作用:栈主要解决程序的运行问题,堆主要解决的是数据的存储问题;
  • 内存分配:堆是不连续的,分配的内存是在运行期确定的,大小不固定,栈是连续的,分配的内存大小要在编译期确定,大小固定。

HotSpot虚拟机对象探秘

对象创建的几种方式

  • 使用new关键字
  • 使用Class的newInstance方法
  • 使用Constructor类的newInstance方法
  • 使用clone方法
  • 使用反序列化

对象创建的主要流程

虚拟机遇到一条new指令时,先检查常量池是否已经加载相应的类,如果没有,必须先执行相应的类加载,类加载通过后,接下来分配内存,若Java堆中内存是绝对规整的,使用“指针碰撞”方式分配内存;如果不是规整的,就从空闲列表中分配,叫做“空闲列表”方式。划分内存时还需要考虑一个并发问题,也有两种方式:CAS同步处理或者本地线程分配缓冲,然后内存空间初始化操作,接着是做一些必要的对象设置,最后执行<init>方法。

在这里插入图片描述
在这里插入图片描述

对象的访问定位

Java程序需要通过 JVM 栈上的引用访问堆中的具体对象。对象的访问方式取决于 JVM 虚拟机的实现。目前主流的访问方式有 句柄直接指针 两种方式。

指针: 指向对象,代表一个对象在内存中的起始地址。
句柄: 可以理解为指向指针的指针,维护着对象的指针。句柄不直接指向对象,而是指向对象的指针(句柄不发生变化,指向固定内存地址),再由对象的指针指向对象的真实内存地址。

句柄访问
Java堆中划分出一块内存来作为句柄池,Java栈的局部变量表中存储对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息,具体构造如下图所示:
在这里插入图片描述
优势:引用中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而引用本身不需要修改。

直接指针
如果使用直接指针访问,引用中存储的直接就是对象地址,那么Java堆对象内部的布局中就必须考虑如何放置访问类型数据的相关信息。
优势:速度更快,节省了一次指针定位的时间开销。由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是非常可观的执行成本。HotSpot 中采用的就是这种方式。

内存溢出异常

Java会存在内存泄漏吗?

内存泄漏是指不再被使用的对象或者变量一直被占据在内存中,虽然Java由GC垃圾回收机制,不再被使用的对象会被GC自动回收,但是还是存在内存泄漏问题,比如:

  • 长生命周期的对象持有短生命周期对象的引用,尽管短生命周期已经不再需要,但是因为长生命周期对象持有它的引用而导致不能被回收。
  • 监听器:释放对象的时候没有删除监听器;
  • 各种连接:比如数据库连接(dataSourse.getConnection()),网络连接(socket) 和 IO 连接,除非其显式的调用了其 close() 方法将其连接关闭,否则是不会自动被 GC 回收的;

什么情况下会发生堆内存溢出,栈内存溢出?

  • 栈溢出:栈是线程私有的,它的生命周期与线程相同,每个方法在执行的时候都会创建一个栈帧,用来存储局部变量表,操作数栈,动态链接,方法出口等信息,栈溢出就是方法执行创建的栈帧超过了栈的深度。
  • 堆溢出:不断的创建对象所致。

堆中主要存储的是对象,如果不断的new对象则会导致堆中的空间溢出。

垃圾收集器

Java中都有哪些引用类型

  • 强引用:发生GC的时候不会回收
  • 软引用:在发生内存溢出之前会被回收
  • 弱引用:在下一次GC时会被回收
  • 虚引用:又称幽灵引用,无法通过虚引用来获得对象,主要用于在GC时返回一个通知

JVM的永久代中会发生垃圾回收吗?

垃圾回收不会发生在永久代,如果永久代满了或是超过了临界值,会触发完全垃圾回收(Full GC),另外,Java8中已经移除了永久代,改为元空间,而且元空间不存在Java虚拟机中,而是保存在本地内存。

分代收集下的年轻代和老年代采用的垃圾回收算法

新生代:主要以复制算法为主

老年代:主要以标记整理为主

  • 在年轻代中经历了N次垃圾回收仍然存活的对象就会被放到老年代中,因此,可以认为老年代中存放的都是一些生命周期比较长的对象;
  • 老年代内存比年轻代内存大很多,当老年代内存满时会触发Major GC(Full GC)。

详细介绍一下CMS垃圾回收器

CMS 是英文 Concurrent Mark-Sweep 的简称,并发收集,是以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器。对于要求服务器响应速度的应用上,这种垃圾回收器非常适合。

CMS 使用的是标记-清除的算法实现的,所以在GC的时候会产生大量的内存碎片,当剩余内存不能满足程序运行要求时,系统将会出现 Concurrent Mode Failure,临时 CMS 会采用 Serial Old 回收器进行垃圾清除,此时的性能将会被降低。(为什么选择Serial Old 作为CMS的后备方案而不选择多线程并行的Parallel Old,原因在于Serial Old可以和年轻代的三种搭配使用,而Parallel Old只能和Parallel Scavenge搭配使用。

CMS回收的过程:

  • 初始标记:(stop-the-world)标记GCRoots可以直接关联的对象;

  • 并发标记:完成余下的GCRoots Tracing标记(用户线程和GC线程并发执行);

  • 重新标记:(stop-the-world)修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录;

  • 并发清除:执行清除操作,和用户线程并发执行;

注意:初始标记和重新标记的时候需要暂停其他应用程序。

JVM自动内存管理机制

JVM自动内存管理机制,主要包括内存回收和内存分配。

内存回收

对于内存回收机制主要围绕“哪些内存需要回收?”,“什么时候回收?”,“如何回收?”三个问题来展开。

1. 哪些内存需要回收?

不可能再被任何途径使用的对象需要被回收。

怎么判断对象是否可以被回收?

判断一个对象是否还可以再被引用的方法有:(1)引用计数法,(2)可达性分析

  • 引用计数法:给对象维护一个计数器,每次被引用计数器的值+1,每次引用被释放,计数器的值-1,当计数器的值为0时,认为它不可能再被引用了;(相互引用造成内存泄漏)
  • 可达性分析:从GCRoots向下搜索,走过的路径为引用链,当一个对象到GCRoots没有任何引用链相连则证明对象不可用。

2. 什么时候回收?

2.1 新生代的回收时机

新的对象需要在Eden区申请内存,但Eden区没有足够的连续空间分配给对象会触发一次Minor GC;

2.2 老年代的回收时机

从新生代过来的对象需要在老年代申请空间,但老年代没有足够的连续空间来分配,会触发一次Major GC(Full GC)

3. 如何回收?

3.1 回收算法

  • 复制算法:把内存空间划分为两个相等的区域,每次只使用其中一个区域,垃圾收集时,遍历当前使用的区域,把存活对象复制到另外一个区域中,最后将当前使用区域的可回收对象进行回收。
    优点:按顺序分配内存即可,实现简单,运行高效,不用考虑内存碎片;
    缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制;
  • 标记-清除算法:标记无用对象,然后进行清除回收
    优点:实现简单,不需要对象进行移动;
    缺点:标记、清除过程效率低,会产生大量不连续的内存碎片;
  • 标记-整理算法:在标记可回收的对象后将所有存活的对象压缩到内存的一端,使它们紧凑地排列在一起,然后对端边界以外的内存进行回收,回收后,已用和未用的内存都各自一边。
    优点:解决了标记-清理算法存在的内存碎片问题
    缺点:仍需要进行局部对象移动,一定程度上降低了效率
  • 分代收集算法:针对不同情况采用不同的垃圾回收算法。

3.2 垃圾收集器

在这里插入图片描述

  • Serial收集器(复制算法): 新生代单线程收集器,标记和清理都是单线程,优点是简单高效;
  • ParNew收集器 (复制算法): 新生代收并行集器,实际上是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现;
  • Serial Old收集器 (标记-整理算法): 老年代单线程收集器,Serial收集器的老年代版本;
  • Parallel Old收集器 (标记-整理算法): 老年代并行收集器,吞吐量优先,Parallel Scavenge收集器的老年代版本;
  • CMS(Concurrent Mark Sweep)收集器(标记-清除算法): 老年代并行收集器,以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短GC回收停顿时间。
  • G1(Garbage First)收集器 (标记-整理算法): Java堆并行收集器,G1收集器是JDK1.7提供的一个新收集器,G1收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。此外,G1收集器不同于之前的收集器的一个重要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代。

内存分配

对象的内存分配主要在堆上进行,对于新对象主要分配在Eden区,少数情况也会直接分配到老年代。

1.1 对象优先在Eden区分配

  • 年轻代内存按照8:1:1的比例分为一个Eden区和两个Survivor(Survivor0、Survivor1)区,大部分对象在Eden区中生成,回收时先将Eden区存活对象复制到一个Survivor0区,然后清空Eden区,当这个Survivor0区也存满了时,则将Eden区和Survivor0区存活的对象复制到另一个Survivor1区,然后清空Eden区和这个Survivor0区,此时Survivor0区是空的,然后将Survivor0区和Survivor1区交换,即保持Survivor1区为空,如此往复。
  • 当Survivor1区不足以存放Eden区和Survivor0区存活的对象时,则将存活对象直接放到老年代,若是老年代也满了,就会触发一次Full GC(Major GC),年轻代和老年代都进行回收。
  • 年轻代发生的GC叫做Minor GC,Minor GC发生的频率比较高(不一定等Eden区满了才触发)
  • 每次从Survivor0到Survivor1移动存活的对象,年龄就加1,当年龄到达15时(默认值),升级为老年代;

1.2 大对象直接进入老年代

-XX:PretenureSizeThreshold参数设置对象大小阈值,大于这个值的对象直接进入老年代;

1.3 长期存活的对象直接进入老年代

Survivor区中的对象每熬过一次Minor GC,年龄就增加1岁,-XX:MaxTenuringThreshold设置年龄阈值,达到阈值的对象直接进入老年代。

1.4 年龄相同对象所占空间超过Survivor区的一半,则大于等于这个年龄的对象直接进入老年代。

1.5 空间分配担保

当新生代采用Eden、Survivor式的复制算法时需要老年代对其进行内存担保(因为minor GC时,如果Survivor1区的容量不足以接纳Survivor0区+Eden区的对象,则他们将全部进入老年代)

Minor GC之前先检查是否可以确保此次GC的安全,先检查老年代的最大可用连续空间是否大于新生代所有对象的总空间,如果成立则可以确保此次minor GC是安全的,如果不成立,检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于尝试一次minor GC, 否则进行一次Full GC。

虚拟机类加载机制

Java类加载机制

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、解析和初始化,最终形成可以被虚拟机直接使用的Java类型。
注意:类是在运行期间第一次使用时动态加载的,而不是一次性加载所有类,如果一次性加载所有类,那么会占用很大的内存。

类的加载方式有两种:

  1. 隐式加载,程序在运行过程中遇到new等方式生成对象时,隐式调用类加载器来加载对应的类到JVM中;
  2. 显式加载:通过class.forName()等方法显式加载需要加载的类。

类的生命周期(前5个步骤是类加载过程)

在这里插入图片描述

加载:

完成以下三件事:

  • 通过类的全限定名获取定义该类的二进制字节流;
  • 将该字节流表示的静态存储结构转换为方法区的运行时存储结构;
  • 在内存中生成一个代表该类的Class对象,作为方法区中该类各种数据的访问入口;

获取二进制字节流有以下几种方式:

  • 从ZIP包读取,常见的有:JAR,WAR
  • 从网络中获取,如:Applet
  • 运行时计算生成,如:动态代理技术,使用ProxyGenerator.generateProxyClass的代理类的二进制字节流;
  • 由其他文件生成,JSP
  • 从数据库中读取

验证:

确保Class文件的字节流中包含的信息是符合当前虚拟机的要求,不会危害虚拟机自身的安全。

验证阶段大致会完成下面4个阶段的检验动作:

  • 文件格式验证验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理,其目的是保证输入的字节流能正确地解析并存储于方法区之内;
  • 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,其主要目的是对类的元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息;
  • 字节码校验:通过数据流和控制流分析,确定程序语义是否是合法的、符合逻辑,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件

  • 符号引用验证:发生在虚拟机将符号引用转化为直接引用的时候,可以看作是对类自身以外(常量池中的各种符合引用)的信息进行匹配性校验,其目的是确保解析动作能正常执行。

准备:

类变量分配内存并设置初始值,使用的是方法区的内存。
注意:这里的内存分配仅包括类变量,不包括实例变量,实例变量会在对象实例化时随着对象一起被分配在堆中,其次这里的初始值“通常情况”下是数据类型的零值。类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次。(单例实现,为什么非静态方法中不能引用静态方法,等等)

解析:

将常量池的符号引用替换为直接引用的过程。

  • 符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中;
  • 直接引用:可以是直接指向目标的指针,相对偏移量或一个能间接定位到目标的句柄,直接引用是和虚拟机实现的内存布局相关的,引用的目标一定存在于内存中。

初始化:

初始化阶段才是真正开始执行类中定义的Java程序代码,初始化阶段是虚拟机执行类构造器方法的过程,并且根据程序去初始化类变量和其他资源。
注意的是:静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。

public class Test {
	static {
		i = 0; // 给变量赋值可以正常编译通过
		System.out.print(i); // 这句编译器会提示“非法向前引用”
	} 
	static int i = 1;
}

使用:

卸载:

什么是类加载器,类加载器有哪些?

虚拟机设计团队把类加载阶段中“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需的类,实现这个动作的代码模块称为“类加载器”。

类加载器负责加载所有的类,其为所有被载入内存中的类生成一个java.lang.Class实例对象。一旦一个类被加载入JVM中,同一个类就不会被再次载入了。正如一个对象有一个唯一的标识一样,一个载入JVM的类也有一个唯一的标识。在Java中,一个类用其全限定类名(包括包名和类名)作为标识;但在JVM中,一个类用其全限定类名和其类加载器作为其唯一标识

主要有以下四种类加载器:

  1. 启动类加载器(Bootstrap ClassLoader)用来加载java核心类库,无法被java程序直接引用。
  2. 扩展类加载器(Extension ClassLoader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。
  3. 系统类加载器(Application ClassLoader):它负责加载用户类路径(ClassPath)上所指定的类库。一般来说,Java 应用的类都是由它来完成加载的。可以通过 ClassLoader.getSystemClassLoader()来获取它。
  4. 用户自定义类加载器,通过继承 java.lang.ClassLoader类的方式实现。

双亲委派模型

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确定在JVM中的唯一性,每个类加载器都有一个独立的类名称空间,类加载器就是根据指定的全限定名称将class文件加载到JVM内存,然后再转化为java.lang.Class对象。
img
双亲委派模型:如果一个类加载器收到了类加载的请求,它首先不会自己去加载这个类,而是把这个请求委派给父类加载器去完成(注意:这里的父子关系一般是通过组合关系来实现的,而不是继承实现的),每一层的类加载器都是如此,这样所有的加载请求都会被传送到顶层的启动类加载器中,只有当父加载无法完成加载请求(它的搜索范围中没找到所需的类)时,子加载器才会尝试去加载类。

双亲委派模型的好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类,相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统中将会有多个不同的Object类,应用程序也将会变得一片混乱。

如果用户编写了一个与rt.jar类库中已有类重名的Java类,将会发现可以正常编译,但是永远无法被加载运行。即使自定义了自己的类加载器,强行用defineClass()方法去加载一个以“java.lang”开头的类也不会成功,会收到一个由虚拟机抛出的异常。

双亲委派模型被破坏的情况

  • 解决历史遗留问题:双亲委派模型是在JDK1.2之后才被引入的,而类加载器和抽象类java.lang.ClassLoader则是JDK1.0时候就已经存在,面对已经存在的用户自定义类加载器的实现代码,Java设计者引入双亲委派模型时不得不做出一些妥协。为了向前兼容,JDK1.2之后的java.lang.ClassLoader添加了一个新的proceted方法findClass(),在此之前,用户去继承java.lang.ClassLoader的唯一目的就是重写loadClass()方法,因为虚拟在进行类加载的时候会调用加载器的私有方法loadClassInternal(),而这个方法的唯一逻辑就是去调用自己的loadClass()。JDK1.2之后已不再提倡用户再去覆盖loadClass()方法,应当把自己的类加载逻辑写到findClass()方法中,在loadClass()方法的逻辑里,如果父类加载器加载失败,则会调用自己的findClass()方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派模型的。
  • 自身缺陷:父类加载器请求子类加载器去完成类加载动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器。
  • 用户对程序的动态性追求:例如OSGi的出现。在OSGi环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为网状结构。

JVM调优

说一下 JVM 调优的工具?

JDK 自带了很多监控工具,都位于 JDK 的 bin 目录下,其中最常用的是 jconsolejvisualvm这两款视图监控工具。

  • jconsole:用于对 JVM 中的内存、线程和类等进行监控;
  • jvisualvm:JDK 自带的全能分析工具,可以分析:内存快照、线程快照、程序死锁、监控内存的变化、gc 变化等。

常用的 JVM 调优的参数都有哪些?

-Xms2g:初始化堆大小为 2g;
-Xmx2g:堆最大内存为 2g;
-XX:NewRatio=4:设置年轻的和老年代的内存比例为 1:4;
-XX:SurvivorRatio=8:设置新生代 Eden 和 Survivor 比例为 8:2;
–XX:+UseParNewGC:指定使用 ParNew + Serial Old 垃圾回收器组合;
-XX:+UseParallelOldGC:指定使用 ParNew + ParNew Old 垃圾回收器组合;
-XX:+UseConcMarkSweepGC:指定使用 CMS + Serial Old 垃圾回收器组合;
-XX:+PrintGC:开启打印 gc 信息;
-XX:+PrintGCDetails:打印 gc 详细信息。

了解过JVM调优没,基本思路是什么?

1. 监控GC的状态

使用各种JVM工具,查看当前日志,分析当前JVM参数设置,并且分析当前堆内存快照和GC日志,根据实际的各区域内存划分和GC执行时间,判断是否进行优化。

举个例子:系统崩溃前的一些现象:

每次垃圾回收的时间越来越长,由之前的10ms延长到50ms左右,Full GC的时间也由之前的0.5s延长到4,5s,Full GC的次数越来越多,最频繁时不到1分钟就进行一次Full GC,老年代的内存越来越大,并且每次Full GC后老年代没有内存被释放,之后系统会无法响应新的请求,逐渐达到OutOfMemoryError的临界值,这个时候就需要分析JVM内存快照dump。

2. 生成堆的dump文件

通过JMX的MBean生成当前的Heap信息,大小为一个3G(整个堆的大小)的hprof文件,如果没有启动JMX可以通过Java的jmap命令来生成该文件。

3. 分析dump文件

借助高配置的Linux,使用Visual VM IBM HeapAnalyzer JDK自带的Hprof工具 Mat(Eclipse专门的静态内存分析工具)打开分析。

4. 分析结果,判断是否需要优化

如果各项参数设置合理,系统没有超时日志出现,GC频率不高,GC耗时不高,那么没有必要进行GC优化,如果GC时间超过1~3s,或者频繁GC,则必须优化。

5. 调整GC类型和内存分配

如果内存分配过大或过小,或者采用的GC收集器比较慢,则应该优先调整这些参数,并且先找1台或几台机器进行beta,然后将优化过的机器和没有优化过的机器进行性能对比,并有针对性的作出最后选择。

6. 不断的分析和调整

通过不断的试验,分析并找到最合适的参数,并将这些参数应用到所有服务器。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!

MySQL数据库 Previous
Java并发编程 Next