当前位置:   article > 正文

独特视角解读JVM内存模型

独特视角解读JVM内存模型


类加载

每一个类被加载的时候,java 虚拟机都监视这个类,看它到底是被启动类加载器加载还是用户定义的类加载器加载。当被装载的类引用了另外一个类的时候,虚拟机就会使用装载第一个类的类装载器装载被引用的类。

由于java虚拟机采取这种方式进行类的加载,所以被装载的类默认情况下只能看到同一个类加载器装载的类。通过这种方法,java体系结构允许在一个java应用程序中建立多个命名空间。运行时的java程序中每一个类加载器都有自己的命名空间,处于不同命名空间中的类不能互相访问。


类加载器体系结构的作用

我们这里只探讨类加载对于JAVA沙箱安全性保障起到的作用:

防止恶意代码去干涉善意的代码

类加载器体系结构可以防止恶意代码干涉善意的代码,这是通过为由不同的类加载器装入的类提供不同的命名空间实现的。命名空间由一系列唯一的名称组成,每一个被装载的类有一个名字,这个命名空间是由JAVA虚拟机为每一个类加载器维护的。

例如: 一旦java虚拟机将一个名为a的类装入一个特定的命名空间,它就不能再装载名为a的类到相同的命名空间了。但是我们可以通过创建多个类加载器在一个java应用程序中创建多个命名空间,从而可以把多个名为a的类都装入一个java虚拟机中。

在java虚拟机中,在同一个命名空间内的类可以直接进行交互,而不同的命名空间中的类甚至不能察觉到彼此的存在。


守护了被信任的类库的边界

长话短说: 类加载体系结构通过双亲委派模型和运行时包两种手段来确保jdk核心类库访问的安全性。

双亲委派模型

类加载器请求另一个类加载器装载类型的过程被形式化,称为双亲委派模式,除了启动类加载器之外,每个类加载都有一个"父类"加载器,当某个类加载器试图去加载某个类之前,它会先请求它的“父类"来装载这个类型。

这个父类再依次请求它自己的父类来装载这个类型,这个委派过程一直向上继续,直到到达启动类装载器。

如果一个类加载器的父类加载器有能力来装载这个类型,则这个类装载器返回这个类型,否则,这个类装载器试图自己来装载这个类型。

在这里插入图片描述
注意: 用户自定义的类加载器默认的父类加载器为应用程序类加载器

在这里插入图片描述
ClassLoader类中定义的双亲委派模板方法:

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            //先检查当前类加载器命名空间下是否存在该类,如果存在说明已经加载过了,直接返回
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                try {
                    //委派父类加载器加载当前类,如果父类为空,那么委派顶层的启动类加载去加载
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                }
                //如果父类加载器没能成功加载指定类,那么再由当前类加载器尝试加载
                if (c == null) {
                    c = findClass(name);
                }
            }
            //是否需要对加载类执行连接过程,连接过程又包含: 验证-准备-解析
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28

双亲委派模型如何保护可信任类库?

  • 类加载器体系结构通过剔除装作被信任的不可靠类,来保护可信任类库的边界的。

因为在双亲委派模型下,启动类加载器可以抢在标准扩展类加载器之前去装载类,而标准扩展类加载器可以抢在类路径加载器之前去加载类,而类路径加载器又可以抢在自定义类加载器之前去加载类。

例如: 当我们利用自定义的类加载器尝试去加载一个与jdk核心类库类名相同的类,如: java.lang.Object, 那么此时启动类加载器将抢先加载,此时我们自定义的类加载器只能使用父类加载成功的类,而无法使用来自第三方恶意伪造的类。

用这种方式,类加载的体系结构可以防止不可靠代码用他们自己的版本来替代可信任的类。


运行时包

双亲委派模型可以避免不可靠代码通过伪装类来替换jdk中的可信任类,但是如果攻击者是想在被信任的包中插入一个全新的类型,例如: java.lang.virtus,那么由于包名与jdk核心类库相同,java.lang.virtus类将具有访问java.lang包下被信任类的权限。

为了解决这个问题,java虚拟机限制只有属于同一个运行时包下的类才可以互相访问,运行时包指由同一个类加载器加载,并且属于同一个包的类型集合。

因此,我们上面定义的java.lang.virtus类由于是通过自定义类加载器加载,所以和java.lang核心类库下的包相比,并不属于同一个运行时包,无法互相访问。

总结: 通过引入运行时包,避免了不可靠代码通过简单的将新类型插入到Java API的包中来获得对包内可见成员的访问权、

private:如果一个元素声明为private,那么只有同一个类下的元素才可以访问它。
default:如果一个元素声明为default,那么只有同一个包下的元素才可以访问它。
protected:如果一个元素声明为protected,那么只有同一个包下的元素或者子类中的元素才可以访问它。
public:如果一个元素声明为public,那么所有位置(不管是否在同一个类中或同一个包下)的元素都可以访问它。
  • 1
  • 2
  • 3
  • 4

JVM内存模型

在这里插入图片描述

  • java虚拟机通过类加载器加载并解析class中类的元数据信息,并将类元数据信息放到方法区中保存,同时会为当前类在堆中创建一个Class对象,作为用户访问方法区中类的元数据入口地址。
  • java栈由一个个栈帧组成,当调用一个方法时,会为当前方法创建一个新的栈帧压入栈中,当方法执行结束,会将栈帧从栈顶弹出,栈顶的栈帧被称为当前活动栈帧。
  • 栈帧主要由局部变量表,操作数据栈,方法元数据指针,方法字节码指令集合组成。

类装载过程

类的装载过程分为以下几步:

  • 装载: 类加载器利用双亲委派模型查找并加载类型的二进制数据到内存
  • 连接: 执行验证,准备以及解析(可选)
    • 验证: 确保被导入类型的正确性
    • 准备: 为类变量分配内存,并将其初始化为默认值
    • 解析: 把类型中的符号引用转换为直接引用

ClassLoader类中的核心方法和注意事项

  • loadClass作为双亲委派的模板方法,定义好了整个双亲委派流程
  • defineClass: 只负责装载过程,当defineClass方法返回一个Class实例时,也就表示指定的class文件已经被找到并装载到方法区了,但是却不一定被连接和初始化了。
  • resolveClass: java虚拟机必须保证ClassLoader的resolveClass方法能够让类装载子系统执行连接动作。

每个类装载器都有自己的命名空间,其中维护着由它装载的类型,所以一个java程序可以多次装载具有同一个全限定名的多个类型。这样一个类型的全限定名就不足以确定在一个java虚拟机中的唯一性,因此,当多个类装载器都装载了同名的类型时,为了唯一标识该类型,还要在类型名前面加上装载该类型的类装载器的标识。

对于每一个被装载的类型,Java虚拟机都会记录装载它的类加载器,当虚拟机解析一个类中引用的其他类的符号引用时,它需要装载当前类的类加载器来加载它所引用的类。


方法区

当虚拟机装载某个类型时,它使用类装载器定位相应的class文件,然后读入这个class文件到内存中,紧接着虚拟机提取其中的类型信息,并将信息存储到方法区。

所有线程都共享方法区,因此他们对方法区数据的访问必须被设计为线程安全的,比如: 假设有两个线程企图去访问一个名为test的类,而这个类还没有被装入虚拟机,那么,这时只应该有一个线程去装载它,而另一个线程则只能等待。

JAVA虚拟机规范规定:

  • 方法区大小不是固定的,可以动态调整
  • 方法区内存不必是连续的,可以在堆(甚至是虚拟机自己的堆)中自由分配。
  • 用户可以指定方法区的初始化大小和最大,最小尺寸。
  • 方法区也可以被垃圾收集,因为虚拟机支持用户自定义的类加载来动态扩展java程序,因此一些类也会成为不再引用的垃圾类,此时我们就可以对这些类进行卸载。

对每个被装载的类型,虚拟机都会在方法区中存储以下类型信息:

  • 类型的全类名
  • 类型的直接付了的全类名
  • 类型是类类型还是接口类型
  • 这个类型的访问修饰符
  • 父接口列表,列表中存储每个父接口的全类名
  • 该类型的常量池
  • 字段信息
  • 方法信息
  • 类变量
  • 加载当前类型的ClassLoader对象引用
  • 当前类型关联的唯一Class类引用

伪代码:

// go语言
type KClass struct {
	accessFlags       uint16  
	name              string
	superClassName    string
	interfaceNames    []string
	constantPool      *ConstantPool
	fields            []*Field
	methods           []*Method
	sourceFile        string
	loader            *ClassLoader
	superClass        *Class
	interfaces        []*Class
	instanceSlotCount uint
	staticSlotCount   uint
	staticVars        Slots
	initStarted       bool
	jClass            *Object
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19

HotSpot虚拟机中,在JDK1.8以后,类变量和字符串常量池一起存放在堆中,类变量存放在当前类型的Class对象中

为了提高需要动态链接的方法的调用效率,虚拟机会为每个装载的非抽象类生成一个方法表,把它作为类信息一部分保存在方法,方法表示一个数组,它的元素是所有它的实例可能被调用的实例方法的直接引用,包括那些从超类继承过来的实例方法。

java多态理解和底层实现原理剖析


从Main方法的执行探究一次类加载的完整过程

/**
 * @author 大忽悠
 * @create 2023/3/18 21:25
 */
public class Test {
    public static void main(String[] args) {
        A a = new A();
        a.flow();
    }
    
    public static class A{
        public void flow(){}
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  1. java Test告诉虚拟机启动类的全类名,虚拟机利用类加载器定位该class文件,读入class文件二进制流数据,从中提取中类型信息放到方法区中。
  2. 通过解释器执行保存在方法区中的字节码,虚拟机开始执行main方法,在执行时,虚拟机会一直持有指向当前Test类的常量池指针

虚拟机不会等到把程序中用到的所有类都装载后才开始运行程序,它只在需要的时候才去装载相应的类。

  1. 方法字节码中第一条new指令告诉虚拟机要在堆中为某个类实例分配内存,new指令的操作数为当前Test类的常量池索引,利用该索引可以定位到常量池中某一项
  2. 此时发现他是一个对A类的符号引用,然后检查A类是否已经加载,思考: 为了让虚拟机尽可能快的从一个类全限定名找到类,底层应该采用何种数据结构存储全限定名和Class引用的映射关系,散列表还是搜索树,还是其他数据结构呢?
  3. 由于A类并没有加载,当前符合引用仅仅只是给出了A类的全限定名,所以使用加载Test类的类加载器来加载A类,从读入的二进制数据中提取类型信息放在方法区中
  4. 然后,虚拟机以一个直接指向方法区中A类的指针来替换常量池中之前的符合引用项,那么下次就可以直接利用这个指针快速访问A类了,这个替换过程称为常量池解析,即把常量池中的符合引用替换为直接引用的过程,该过程通过在方法区中搜索被引用的元素实现的,在这期间可能又需要装载其他类。

类加载阶段又分为: 加载,链接,初始化
加载阶段需要考虑双亲委派机制
链接阶段分为: 验证,准备(类变量赋予零值),解析(一般为懒解析,此步骤跳过)
类初始化阶段:执行类构造函数,通过搜集静态代码块和静态变量赋值语句组成

在这里插入图片描述
7. 虚拟机在堆中为当前对象分配内存(新生代),此时,需要借助方法中存储的类型信息,获取分配一个A对象实例需要多少堆空间,虚拟机总是能够通过存储于方法区的类型信息来确定一个对象需要多少内存
8. 当虚拟机确定一个对象的大小后,它就在堆上分配对应大小的空间,并把这个对象实例变量赋予零值
9. 把新生成的对象引用压入栈中
10.通过解释器执行保存在方法区中当前类的无参构造函数,并将A对象引用作为隐式参数传入方法,然后对当前实例对象进行初始化

对象实例初始化分为三步: 分配内存,调用对象构造函数进行初始化,将内存地址赋予变量,如果该过程出现指令重排,那么并发情况下可能会导致空指针异常,因此需要考虑在双重锁单例实现中使用volatile修饰实例变量


Java堆中存储的对象实例数据通常包含以下几个部分:

  • 所属类及其所有超类声明的实例变量
  • 指向方法区中类型信息的元数据指针

堆空间的设计常见有两种模型:

  • 堆分为两部分: 一个句柄池,一个对象池。句柄池中每个条目分为两部分: 一个指向对象实例变量的指针,一个指向方法区类型数据的指针。
    • 好处: 有利于堆碎片的整理,当移动对象池中的对象时,句柄部分只需要更改一下指针指向对象的新地址即可
    • 缺点: 访问对象的实例数据都要经过两次指针传递
  • 另一种设计方式是使对象指针直接指向一组数据,该组数据包括对象实例数据以及指向方法区中类数据的指针
    • 好处: 只需要一次指针传递即可访问对象实例数据。
    • 缺点: 移动对象变得更加复杂,它必须在整个运行时数据区中更新指向被移动对象的引用。

为什么虚拟机必须能通过对象引用得到类的元数据?

  • 程序运行时需要转换某个对象的引用为另一种类型,虚拟机需要检查这种类型转换是否允许,被转换的对象是否的确是被引用的对象或者它的超类型
  • 当程序中调用某个实例方法时,虚拟机必须进行动态绑定,换句话说,它不能按照引用的类型来就决定将要调用的方法,而必须根据对象的实际类,为此,虚拟机必须再次通过对象的引用去访问类数据

方法表

不管虚拟机的实现使用什么样的对象表示法,很可能每个对象都有一个方法表,因为方法表加快了调用实例方法时的效率,在一些有着严格内存资源限制的实现,或许它们根本不可能有足够的额外内存资源来存储方法表。一个实现如果使用方法表,那么仅仅使用一个指向对象的引用,就可以很快地访问到对象的方法表。

增加方法表后,每个对象的实例数据都包含一个指向特殊数据结构的指针,这个数据结构位于方法区,它包括两部分:

  • 一个指向方法区对应类元数据的指针
  • 此对象的方法表

方法表是一个指针数组,其中每一项都是一个指向"实例方法数据"的指针,方法表指向的实例方法数据包括以下信息:

  • 此方法的操作数栈和局部变量区的大小
  • 此方法的字节码
  • 异常表

这些信息足够虚拟机去调用一个方法,方法表中包含有方法指针,该指针指向当前类或者其超类什么的方法数据,也就是说,方法表所指向的方法可能是此类声明的,也可能是它继承下来的。

方法表中只存储需要动态连接的方法,即通过InvokeVirtual和InvokeInterface指令调用的需要考虑多态实现的方法。

java多态理解和底层实现原理剖析


对象锁

堆上的对象还有一个逻辑部分,那就是对象锁(ObjectMonitor),这是一个互斥对象,虚拟机中每个对象都有一个对象锁,它被用于协调多个线程访问一个对象时的同步。

在任何时刻,只能有一个线程"拥有"这个对象锁,因此只有这个线程才能访问该对象的数据,此时其他希望访问这个对象的线程只能等待,直到拥有对象锁的线程释放锁。

当某个线程拥有一个对象锁后,可以继续对这个锁追加请求,即锁可重入。

很多对象在整个生命周期内都没有被任何线程加锁,在线程实际请求某个对象的锁之前,实现对象锁需要的数据是不必要的。很多实现不在对象自身内部保存一个指向锁数据的指针,而只有当第一次需要加锁的时候才分配对应的锁数据,但这时虚拟机需要用某种间接方法来联系对象数据和对应的锁数据,例如: 把锁数据放在一个以对象地址为索引的搜索树中,或者在对象头中分配地址作为锁指针。

java中的锁实际是对管程的一种实现,管程用于同步多线程对共享资源的访问和等待通知机制,管程资源是懒创建的,用到的时候才会创建,并且管程对象数据存储和对象本身数据存储是分开的,但是需要某种机制将两者关联起来。


数组对象

数组的内部表示,在Java中,数组是真正的对象,和其他对象一样,数组总是存储在堆中。同样,和普通对象一样,实现的设计者将决定数组在堆中的表现形式。

和其他所有对象一样,数组也拥有一个与它们的类相关联的Class实例,所有具有相同维度和类型的数组都是同一个类的实例,而不管数组的长度(多维数组每一维度的长度)是多少。


程序计数器

每个线程都有它自己的PC寄存器,它是在线程启动时创建的。

PC寄存器的大小是一个字长,因此它既能够持有一个本地指针,也能够持有一个returnAddress。

当线程执行某个Java方法时,PC寄存器的内容总是下一条将被执行指令的“地址”,这里的"地址"可以是一个本地指针,也可以是方法字节码中相对于该方法起始指令的偏移量。

如果当前线程正在执行一个本地方法,那么此时PC寄存器的值是"undefined"。


Java栈

每启动一个新线程时,Java虚拟机都会为它分配一个Java栈,Java栈以帧为单位保存线程的运行状态,虚拟机只会直接对Java栈执行两种操作: 以帧为单位的压栈或出栈。

某个线程正在执行的方法称为该线程的当前方法,当前方法使用的栈帧称为当前帧,当前方法所属的类称为当前类,当前类的常量池称为当前常量池

每当线程调用一个Java方法时,虚拟机都会在该线程的Java栈中压入一个新帧,在执行这个方法时,使用该帧来存储参数,局部变量,中间运算结果等数据。

Java栈上的数据都是线程私有的,因此我们无需考虑多线程情况下栈数据的访问同步问题。

像方法区和堆一样,Java栈和帧在内存中也不必是连续的,帧可以分布在连续的栈里,也可以分步在堆里,或者二者兼有之。

Java栈和栈帧的实际数据结构由虚拟机的实现者决定,某些实现允许用户指定Java栈的初始大小和最大最小值。


栈帧

栈帧由: 局部变量区,操作数栈和栈帧三部分组成。

局部变量区和操作数栈的大小要视对应的方法而定,它们是按字长计算的。编译器在编译时就确定了这些值并放在class文件中,而帧数据区的大小依赖于具体的实现。

当虚拟机调用某个方法时,它从对应的类的类型信息中得到此方法的局部变量区和操作数栈的大小,并据此分配栈帧内存,然后压入Java栈中。


局部变量区

局部变量区,Java栈帧的局部变量区被组装为一个字长为单位,从零开始计数的数组。字节码指令通过从0开始的索引来使用其中的数据,类型为int,float,reference和returnAddress的值在数组中占据一项,而类型为byte,short和char的值在存入数组前都将被转换为int值,因次同样占据一项。

但是类型为long和double的值在数组中却占据连续的两项。(32位操作系统,64位操作系统字长为8自己,只占据一项)

局部变量区包含对应方法的参数和局部变量,编译器按声明顺序把这些参数放入局部变量数组。

对于一个实例方法调用而言,参数this总是作为隐式参数传入的,它用来表示调用该方法的对象本身。

在Java中,所有的对象都按引用传递,并且都存储在堆中,永远不会在局部变量区或操作数栈中发现对象的拷贝,只会有对象引用。

当两个局部变量的作用域不重叠时,可以使用局部变量数组中一个索引指代两个局部变量。


操作数栈

操作数栈被组织为一个以字长为单位的数组,但是它不是通过索引来访问的,而是通过标准的栈操作–压栈和出栈来访问的。

虚拟机在操作数栈中存储数据的方式和在局部变量区中是一样的,如: int,long,float,double,reference和returnType的存储。对于byte,short以及char类型的值在压入操作数栈之前也会被转换为int。

不同于程序计数器,java虚拟机中没有寄存器,程序计数器也无法被程序指令直接访问。Java虚拟机的指令是从操作数栈中而不是从寄存器中取得操作数的,因此它的运行方式是基于栈的,而不是寄存器的。

虽然指令也可以从其他地方取得操作数,比如: 字节码流中跟随在操作码(代表指令的字节)之后的字节中或从常量池中,但是主要还是从操作数栈中获得操作数。

虚拟机把操作数栈作为它的工作区,大多数指令都要从这里弹出数据,执行运行,然后把结果压回操作数栈。


其他数据

帧数据区除了局部变量表和操作数栈外,Java栈帧还需要一些数据来支持常量池解析,正常方法返回以及异常派发机制,包括还有一些用于调试的数据等,这些信息都保存在Java栈帧的数据区中。

常量池指针

Java虚拟机中大多数指令都涉及到常量池入口,有些指令仅仅是从常量池中取出数据然后压入Java栈(这些数据的类型包括int,long,float,double和string),还有些指令使用常量池的数据来指示要实例化的类或数组,要访问的字段,或者要调用的方法。还有些指令需要常量池中的数据才能确定某个对象是否属于某个类或实现了某个接口。

每当虚拟机要执行某个需要用到常量池数据的指令时,它都会通过帧数据区中指向常量池的指针来访问它,以前讲过,常量池中对类型,字段和方法的引用在开始时都是符号。当虚拟机在常量池中搜索的时候,如果遇到指向类,接口,字段或者方法的入口,假若他们仍然是符号,虚拟机那时候会进行解析。


异常表

为了处理java方法执行期间的异常退出情况,帧数据区还需要保存对此方法异常表的引用。它定义了在这个方法的字节码中受catch子句保护的范围,异常表中的每一项都有一个被catch子句保护的代码的起始和结束位置,可能被catch的异常类在常量池中的索引值,以及catch子句内的代码开始的位置。

当某个方法抛出异常时,虚拟机根据帧数据区对应的异常表来决定如何处理。如果在异常表中找到了匹配的catch子句,就会把控制权转交给catch子句内的代码。如果没有发现,方法会立即异常中止,然后虚拟机使用帧数据区的信息恢复发起调用的方法的帧,然后在发起调用的方法的上下文中重新抛出同样的异常。


本地方法栈

对于运行中的Java程序而言,它还可能会用到一些跟本地方法相关的数据区。当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。本地方法可以通过本地方法接口来访问虚拟机的运行时数据区,但不止于此,它还可以做任何它想做的事情。比如,它甚至可以直接使用本地处理器中的寄存器,或者直接从本地内存的堆中分配任意数量的内存等等。总之,它和虚拟机拥有同样的权限(或者说能力)。

本地方法本质上是依赖于实现的,虚拟机实现的设计者们可以自由地决定使用怎样的机制来让Java程序调用本地方法。

任何本地方法接口都会使用某种本地方法栈。当线程调用Java方法时,虚拟机会创建一个新的栈帧并压入Java栈。然而当它调用的是本地方法时,虚拟机会保持Java栈不变,不再在线程的Java栈中压人新的帧,虚拟机只是简单地动态连接并直接调用指定的本地方法。可以把这看做是虚拟机利用本地方法来动态扩展自己。就如同Java虚拟机的实现在按照其中运行的Java程序的吩咐,调用属于虚拟机内部的另一个(动态连接的)方法。

很可能本地方法接口需要回调Java虚拟机中的Java方法(这也是由设计者决定的),在这种情形下,该线程会保存本地方法栈的状态并进人到另一个Java栈。

就像其他运行时内存区一样,本地方法栈占用的内存区也不必是固定大小的,它可以根据需要动态扩展或者收缩。某些实现也允许用户或者程序员指定该内存区的初始大小以及最大、最小值。


执行引擎

Java虚拟机规范中,执行引擎的行为使用指令集来定义。对于每条指令,规范都详细规定了当实现执行到该指令时应该处理什么,实现的设计者决定如何执行字节码,实现可以采取解释、即时编译或直接用芯片上的指令执行,还可以是它们的混合。

运行中Java程序的每一个用户线程都是一个独立的虚拟机执行引擎的实例。从线程生命周期的开始到结束,它要么在执行字节码,要么在执行本地方法


解释器

解释器负责解释执行当前方法字节码流中每条指令。

指令集: 方法的字节码流是由Java虚拟机的指令序列构成的,每一条指令包含一个单字节的操作码,后面跟随0个或多个操作数。

操作码表明需要执行的操作;操作数向Java虚拟机提供执行操作码需要的额外信息。操作码本身就已经规定了它是否需要跟随操作数,以及如果有操作数的话,它是什么形式的。

很多Java虚拟机的指令不包含操作数,仅仅是由一个操作码字节构成的,根据操作码的需要,虚拟机可能除了跟随操作码的操作数之外,还需要从另外一些存储区域得到操作数,当虚拟机执行一条指令的时候,可能使用当前常量池中的项、当前帧的局部变量中的值、或者位于当前帧操作数栈顶端的值。

抽象的执行引擎每次执行一条字节码指令。Java虚拟机中运行的程序的每个线程(执行引擎实例)都执行这个操作。执行引擎取得操作码,如果操作码有操作数,取得它的操作数。它执行操作码和跟随的操作数规定的动作,然后再取得下一个操作码。这个执行字节码的过程在线程完成前将一直持续,通过从它的初始方法返回,或者没有捕获抛出的异常都可以标志着线程的完成。

执行引擎会不时遇到请求本地方法调用的指令。在这个时候,虚拟机负责试着发起这个本地方法调用。如果本地方法返回了(假设是正常返回,而不是抛出了一个异常),执行引擎会继续执行字节码流中的下一条指令。

本地方法是Java虚拟机指令集的一种可编程扩展。如果一条指令请求一个对本地方法的调用,执行引擎就会调用这个本地方法。运行这个本地方法就是Java虚拟机对这条指令的执行。当本地方法返回了,虚拟机继续执行下一条指令。如果本地方法异常中止了(抛出了一个异常),虚拟机就按照好比是这条指令抛出这个异常一样的步骤来处理这个异常。


即时编译器

自适应优化的虚拟机开始的时候对所有的代码都是解释运行,但是它会监视代码的执行情况。大多数程序花费80%~90%的时间用来执行10%~20%的代码。因为可以监视程序的执行情况,所以虚拟机可以意识到哪些方法是程序的“热区”—就是那10%~20%的代码,它们占整个执行时间的80%~90%。

当自适应优化的虚拟机判断出某个特定的方法是瓶颈的时候,它启动一个后台线程,把字节码编译成本地代码,非常仔细地优化这些本地代码。同时,程序仍然通过解释来执行字节码。因为程序没有中途挂起,并且只编译和优化那些“热区”(大约10%~20%的代码),虚拟机可以比传统的即时编译更注重优化性能。

自适应优化技术使程序最终能把原来占80%~90%运行时间的代码变为极度优化的、静态连接的C++本地代码,而使用的总内存数并不比全部解释Java程序大多少。换句话说,就是更快了。自适应优化的虚拟机可以保留原来的字节码,等待方法从热区移出(程序的热区在执行的过程中可能会转移)。当方法变得不再是热区的时候,取消那些编译过的代码,重新开始解释执行那些字节码。

编译后生成的本地代码会存储在当前方法所在方法区中的类型数据中,因此会占据一定的内存空间。


内联

Java程序的运行时特征,就是方法调用和动态派发的高频度发生,它们从两个方面影响性能。首先,每次动态派发都会产生相关的管理费用;其次,更重要的是方法调用降低了编译器优化的有效性。

这个问题的标准解决方案就是内嵌—把被调用方法的方法体直接拷贝到发起调用的方法中。内嵌消除了方法调用,因此可以让优化器处理更多的代码。这可能令优化器工作更有效,代价就需要更多的运行时内存。

麻烦之处在于,在面向对象的语言(比如Java和C++)中实现内嵌,要比非面向对象的语言(比如C)更加困难,因为面向对象语言使用了动态派发。在Java中比在C++中更加严重,因为Java的方法调用和动态派发的频度要比C++高得多。

一个C程序的标准优化静态编译器可以直接使用内嵌,因为每一个函数调用都有一个函数实现。对于面向对象语言来说,内嵌就变得复杂了,因为动态方法派发意味着一个函数调用可能有多个函数实现(方法),换句话说,虚拟机运行时根据方法调用的对象类,可能会有很多不同的方法实现可供选择。

内嵌一个动态派发的方法调用,一种解决办法就是把所有可能在运行时被选择的方法实现都内嵌进去。这种思路的问题在于,如果有很多方法实现,就会让优化后的代码变得非常大。

自适应编译比静态编译的优点就在于,因为它是在运行时工作的,它可以使用静态编译器所无法得到的信息。比如说,对于一个特定的方法调用,就算有30个可能的方法实现,运行时可能只会有其中的两个被调用。自适应方法就可以只把这两个方法内嵌,有效地减少了优化后的代码大小。


线程模型

Java虚拟机规范定义了线程模型,该模型设计主要考虑以下几个方面:

  • 便于在不同体系结构上进行实现 ----> 使实现的设计者,在可能的情况下使用本地线程,否则,设计者可以在它们的虚拟机内部实现线程机制。
  • 线程优先级问题 —> 默认提供10个优先级,但是只规定了高优先级的线程获得大多数CPU时间,低优先级的线程获得较少的CPU时间
  • 支持线程同步 —> 需要支持对象锁定,线程等待和通知
  • 线程的行为 —> 通过术语-- 变量,主存和工作内存来定义的

这里重点聊聊java线程行为的定义:

  • 每个Java虚拟机实例都有一个主存,用于保存所有的程序变量(对象的实例数据,数组的元素以及类变量)
  • 每一个线程都有一个工作内存,线程用它来保存局部变量和方法参数。

Java虚拟机定义了许多规则,用来管理线程和主存之间的底层交互行为:

  • int等不大于32位的基本类型的操作都是原子操作
  • 32jvm对于long和double变量,把它们作为2个原子性的32位值来对待,而不是一个原子性的64位值。编码人员需要将共享 的 64 位值声明为 volaitle 的或将其程序正确同步以避免可能的并发问题。
  • 管理线程的低层规则,主要规定当前线程何时可以做以及何时必须做以下的事情:
    • 把变量的值从主存拷贝到它的工作内存
    • 把值从它的工作内存写回主存

在特定的条件下,规则制定了精确的和可预测的读写内存的顺序,然后再另一些条件下,规则没有规定任何顺序,此时如果访问某个没有被同步的变量,可能会产生意想不到的结果。

具体的规则则是通过happens-before规则进行的限制,详情可以阅读本文: 独特视角带你走进Java并发编程的世界

volatile只能保证可见性不能保证原子性,但用volatile修饰long和double可以保证其操作原子性!

long 和 double 都是 8 字节长度的类型,也就是64 位。JVM 规范出来的较早,那时候处理器还不能处理 64 位字长,所以 JVM 规范里定义的是 32 位字长的读写是原子的,而 64 位字长需要分成两次来操作


补充说明: Java内存模型(JMM)

java语言是跨平台的,因此Java需要提供自己的一套内存模型来屏蔽不同操作系统的内存模型的差异。

JMM主要干了两件事:

  • 抽象了Java线程和主内存之间的关系(工作内存-变量-主内存)
  • 定义了一组规范,使得JVM能够按需禁用编译器和CPU层面的指令重排优化,避免产生有序性问题,以及按需禁用CPU缓存,避免产生可见性问题

这套规范主要包括volatile,synchroized,final三个关键字的解析,和8个happens-before规则。

在这里插入图片描述

在这里插入图片描述

独特视角带你走进Java并发编程的世界

JMM(Java 内存模型)详解


声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/不正经/article/detail/177821
推荐阅读
相关标签
  

闽ICP备14008679号