Java虚拟机

来源:互联网 发布:python实现自动签到 编辑:程序博客网 时间:2024/06/04 23:32

java虚拟机

Java支持平台无关性、安全性和网络移动性。而Java平台由Java虚拟机和Java核心类所构成,它为纯Java程序提供了统一的编程接口,而不管下层操作系统是什么。正是得益于Java虚拟机,它号称的“一次编译,到处运行”才能有所保障。

java虚拟机主要完成了几大任务:

  • 将java文件编译为.class文件、解析并执行.class,借助他,使得java能够实现平台无关的特性。
  • 实现了自动内存管理,垃圾回收的存在使得编程时无需关注内存的分配与释放。

一、内存区域

JVM对内存进行了清晰的划分。

从程序执行讲起

为了更清楚地了解内存区域的各个区域之间的关联性,我们从一个程序的执行开始讲起。

1、要执行一个程序,首先虚拟机要对其进行解析,将程序转化为虚拟机可识别的格式,将一些类、编译后的代码、常亮等放在方法区当中。

2、一个程序是从main()方法中开始的,我们都知道,main()方法其实对应了一个线程,所以,要执行一个方法首先要建立起一个线程,所以将有了虚拟机栈,在方法调用时通过出栈、入栈的方式进行控制。同时,为了确保在多个线程切换的时候,程序能够回到正确的位置继续执行,每个线程都分配了对应的程序计数器。有虚拟机栈,对于java的本地方法,也有对应的本地方法栈,用于管理如wait、signal等一些本地方法。

3、当程序执行时,新建一个对象时,则会在中为其分配内存,而在栈中存储对应的引用指向堆中的对象实例。

另外,还有常量池,用于存放程序中的常量。直接内存,作为独立于虚拟机运行时数据区的一块内存,使得一些本地方法可以在堆外直接使用内存。

运行时的数据区

以上提及的方法区、java虚拟机栈、程序计数器、本地方法栈、堆、常量池,都属于jvm的运行时的数据区。

下图展示了抽象的虚拟机的内部体系结构。

image

二、垃圾回收

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙” , 墙外面的人想进去,墙里面的人却想出来。这就是垃圾回收,垃圾回收并非java创造,最早的垃圾回收是在神一样的Lisp语言中产生的。

java中,所有的对象都是分配在堆中进行统一管理的。相较于各个线程独自开辟空间存储对象,采取这种统一管理的方式的好处,我的理解是:统一的管理有利于充分利用内存,也便于管理,当进行内存回收时,由于空闲区域是共享的,所以每个线程都可以使用,做到了空间使用的最大化。

要对内存空间进行动态管理,自动进行垃圾回收。要解决以下问题:

1、哪些对象需要回收?
2、如何高效的进行垃圾回收?
3、什么时候进行回收?
下面,就从对这几个问题展开。

1、哪些对象需要回收?

要回收,当然要保证当对象不会再被程序任何地方使用,这些对象便能够进行回收了。即这个对象不再“存活”。

要判断一个对象是否还存活,主要有

1)引用计数法,这种方法通过为每一个对象添加一个引用计数器,每当有一个地方引用它时,就加一,当引用时效时,就减一。这种方式简单方便,但无法解决循环引用的问题。

2)可达性分析法。这是目前各个语言主流的实现方式。他通过一系列的GCRoots对象作为起点向下搜索,若没有任何路径能够到达某个对象,则认为这个对象不可达,即不再“存活”的对象。能够作为GCRoots的对象有:虚拟机栈中的对象、方法区中的静态属性的对象、方法区的常量对象、本地方法栈中引用的对象。其他存活的对象都能够通过这些对象的引用链到达。

2、如何高效的进行垃圾回收?

找到了程序中不再存活的对象,下一步便是清理对象以释放内存空间。主要有两类的方案来处理:将需要回收的对象清除,或者类似Windows磁盘整理的方式,将有用的对象整理后释放掉其他的空间。所以,目前有以下几种垃圾回收的机制:

1)标记清除。

标记需要回收的对象,释放内存。这种方案方便快速但会产生大量的内存碎片。

2)标记-整理算法。

标记需要回收的对象,然后相Windows进行磁盘整理一样,将存活的对象向一端移动。这种方式不会产生内存碎片,但效率就低了。如果存活对象数量很多的情况下,就悲剧了。

3)复制算法。

将内存划分为两块等大小的区域,每次使用一块,进行GC时,将存活的数据复制到另一块区域中。这种方式方便且不会产生内存碎片,就是利用率太低了。考虑到在实际情况中,大量的对象都是“朝生夕死”的,所以,升级版的复制算法将内存划分为Eden和Survivor(8:1),Eden用于正常的对象分配使用,当进行回收时,将存活的对象复制到Survivor区中,这样,内存的使用率达到了80%,顿时好多了。但如果在一次GC时,存货的对象很多,Survivor区不够用时怎么办?则需要进行分配担保了,请度娘。

4)分代收集

将java的堆分为老年代和新生代。对于新生代,采用复制算法。而对于老年代,则使用标记-清除或者标记-整理的方法。

3、什么时候进行回收?

  • java中,有System.gc()的方法,但该方法只是建议JVM进行垃圾回收,最终的决定权由JVM控制。

  • 当新生代Eden的空间无法进行新对象的分配时,VM会进行一次Minor GC。

新生代GC( Minor GC) : 指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性, 所以Minor GC非常频繁, 一般回收速度也比较快。

老年代GC( Major GC/Full GC) : 指发生在老年代的GC,出现了Major GC,经常会伴随至少一次的Minor GC( 但非绝对的, 在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程) 。 Major GC的速度一般会比Minor GC慢10倍以上。

三、类加载机制

一个java类在编写完成后,首先要被编译成.class文件。然后,需要通过类加载器将.class文件装载到虚拟机中,才能够运行。将.class文件的字节流转换为虚拟机能够识别的格式(转化为java.lang.Class类的一个实例)的过程,即解析二进制流并载入内存的行为,就是类加载器的行为。

为什么要有类加载器?

java为了实现平台无关性,将所有的类都编译成.class文件,为了解析.class类文件,于是,类加载器的出现就变得自然而然了。只是这个名字听起来比较高级了点,在我看来就是文件解析器。

而类加载器重要的一点是,他是在程序执行的时候对类进行加载的,这就使得对类的操作变得更加灵活。比如,在程序执行时可以通过网络传输一个.class文件,加载到类中执行;可以在程序执行时为类动态的添加一些行为(ioc)…

类加载器做了哪些事?

如下图:
image

1、 加载:查找并加载类的二进制数据。

2、 链接:
验证:确保被加载类的正确性。
准备:为类的静态变量分配内存,并将其初始化为默认值;
解析:把类中的符号引用转换为直接引用。

3、 初始化:为类的静态变量赋予正确的初始值。

类加载器的双亲委派模型

这个我认为是类加载器很重要的一个概念。虽然实现上很简单,只是几个继承的关系。

来看个图:

image

最上层的BootStrap类加载器是个本地方法,用于加载<JAVA_HOME>\lib中运行时必须的特定类库,比如rt.jar。

ExtClassLoader,用于加载<JAVA_HOME>\lib\ext中的扩展类。

APPClassLoader,应用程序类加载器,负责加载用户指定类路径上的jar文件,开发人员可以通过getSystemClassLoader()方法获取。

类加载器之间的这种层次关系, 称为类加载器的双亲委派模型(Parents DelegationModel) 。 这种方式,下级的类加载器要加载一个类时,会先委托上级的类进行加载,若上级的类加载器无法加载时,才由下级的类进行处理。(当然, 你也可以完全抛开这个规范,将所有的类都由自定义的类加载器进行加载。)遵循双亲委派模型的好处是,最顶层的类是jre必须的类,如Object类,这样,即便用户自定义了一个Object类,也不会被加载(因为最顶层仍会加载系统最基础的类),即可以保证基础类库不会被覆盖。

自定义类加载器的应用场景

首先,我们需要清楚,自定义类加载器的目的是为了在读取.class文件之后,进行一些自定义的操作,再加载成对应的类。

  • 需要加密传输.class文件时,对.class文件加密后,系统默认的类加载器便无法正确识别文件的内容,所以,需要自定义类加载器对其解密之后,再交由应用程序类加实现载器进行加载。
  • cglib、asm包中,对类进行添加方法等方法,是需要对.class的字节流进行修改后,再交由类加载器进行加载。
  • OSGI,为了实现各个模块之间的可见性,通过为每个模块建立对应的类加载器得以实现。

四、对并发的支持

JVM为什么会讲到并发呢?因为多线程。多线程如何实现?多线程间如何解决数据竞争的问题?这些都是虚拟机需要考虑的事项。

1、虚拟机对多线程的支持

Java虚拟机规范中,定义了一套Java内存模型,使其在各个平台下的访问都能够达到一致的内存访问效果,在此之前,主流程序语言( 如C/C++等) 直接使用物理硬件和操作系统的内存模型,因此, 会由于不同平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常, 而在另外一套平台上并发访问却经常出错,因此在某些场景就必须针对不同的平台来编写程序。

java的内存模型

image

各个线程中,都有属于自己的工作内存,同时,还有各个线程间共享的主内存。所有的变量都存储在主内存中,线程需要通过特定的操作(read、load)将变量的副本拷贝到工作内存中,才能够使用,换句话说,线程只能直接访问自己的工作内存。

在java虚拟机规范中,定义了8个操作,对变量进行操作:

  • lock( 锁定) :作用于主内存的变量,它把一个变量标识为一条线程独占的状态。

  • unlock( 解锁) :作用于主内存的变量,它把一个处于锁定状态的变量释放出来, 释放后的变量才可以被其他线程锁定。

  • read( 读取) : 作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中, 以便随后的load动作使用。

  • load( 载入) : 作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

  • use( 使用) : 作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎, 每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。

  • assign( 赋值) : 作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

  • store( 存储) : 作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中, 以便随后的write操
    作使用。

  • write( 写入) : 作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

另外,规定了进行这些操作的规范,以保证数据操作的正确性。

多线程的实现

多线程的实现,与其说是由虚拟机负责,不如说与操作系统更相关。我们都知道,实现线程有三种方式:

  • 使用内核线程实现。

image

这种实现方式直接由操作系统内核控制的线程来实现。用户往往使用内核线程的一种高级接口——轻量级进程(LWP),即我们通常意义上的线程,来实现,一个程序中的线程对应内核线程。

这种方式实现上简单,但各种调用都都需要切换到内核态进行操作,所以系统调用产生的,切换的代价比较高。

  • 使用用户线程实现

image

完全建立在用户空间上的线程,可以理解为使用一个内核态的线程模拟出多线程的效果。这种方式灵活性高,且不需要切换到内核态进行操作,大大减少了开销。只是,由于用户线程的底层仍是一个线程,若底层的内核线程挂起,会导致基于该内核线程的所有用户线程都被无条件挂起。并且,实现难度很大。

  • 使用用户线程+轻量级线程实现

image

这种方式中,有别于纯粹的用户线程的将所有创建的线程对应到一个内核线程的方式,他将N个用户线程映射到M个内核线程中,这样,既保留了用户线程高度灵活、操作代价低的特质,又避免了由于内核线程阻塞而导致所有用户线程都被阻塞的风险。

2、线程安全

Java中,线程安全由强到弱分为5类。不可变、绝对线程安全、 相对线程安全、 线程兼容和线程对立。

  • 不可变。这是最简单的控制方法,final对象由于状态不会变化,所有不存在不安全的因素。如java中的String类型
  • 绝对线程安全。不管运行时环境如何,调用者都不需要任何额外的同步措施,将能够保证线程安全。实际上,大部分类都无法保证到这一点。
  • 相对线程安全。它需要保证对这个对象单独的操作是线程安全的, 我们在调用的时候不需要做额外的保障措施,但是对于一些特定顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。 我们目前使用的大多数线程安全的类都是这样的,比如vector的get、add、remove这些单独的操作都是线程安全的,但若需要连续操作,比如getSize之后,然后get所有的元素输出,就需要外部单独的同步保证数据的正确性(否则,可能在半途数据被remove而导致获取的数据量不正确)。
  • 线程兼容。如我们熟悉的ArrayList和HashMap本身是线程不安全的,但我们可以通过外部同步手段对其安全地使用
  • 线程对立。线程对立是指无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码。这种情况比较少见。一个线程对立的例子是Thread类的suspend()和resume()方法,如果有两个线程同时持有一个线程对象,一个尝试去中断线程,另一个尝试去恢复线程,如果并发进行的话,无论调用时是否进行了同步,目标线程都是存在死锁风险的,如果suspend()中断的线程就是即将要执行resume()的那个线程,那就肯定要产生死锁了。

线程安全实现方法

1、互斥同步

这种方式,主要通过synchronized关键字对某些操作进行同步,若某个线程在synchronized的临界区内,其他任何进入要进入临界区的线程都会阻塞、进入等待队列。

2、非阻塞同步

与上一中线程会阻塞进入等待队列的方式相比,考虑到线程的状态切换是有较大代价,而很多情况下线程只需等待很短的时间将能够获得锁,引入了乐观的并发机制——非阻塞同步。在进入同步块时,若无法获得锁,便不断while循环尝试获得锁,直到成功。自旋锁便能够符合这种情形,并且,你可以设置进行自旋的次数,超过该次数,则线程挂起等待。而JDK1.6引入的更高级的自适应自旋锁能够自动根据前一个锁自旋及拥有锁的时间来设定自旋的次数。so smart!


参考:

《Java虚拟机》

理解Java虚拟机体系结构

0 0
原创粉丝点击