Java理解笔记------杂项
jvm内存模型
- 堆内存
- 方法区
- 程序计数器
- Java虚拟机栈
- 本地方法栈
- 运行时常量池
- 直接内存
堆内存
这是jvm中最大的一块区域,基本上所有的变量和对象实例都在这一块区域存放,所有线程共享的。但是随着jdk1.6的发布,JIT即时编译技术的优化、逃逸分析技术的成熟,所有对象在堆上分配也不是那么绝对的,只要逃逸分析出对象不会逃逸出线程或者栈,那么原来在堆上分配的变量可以直接在栈上分配,这样在方法调用结束、或者线程结束的时候,栈上内存自动释放,可以大大减少堆内存上的GC收集,提高jvm整体运行效率。会抛出OutOfMemeryError错误,堆的生命周期和jvm的生命周期相同,随着JVM的启动而产生,JVM的终止而释放
方法区
这一部分也是所有线程共享的。方法区存放的是类信息、常量、静态变量和即时编译器编译后的代码,方法区也叫永久代,这里存放的都是类相关的信息,还有字符变量和引用变量。会抛出OutOfMemeryError错误,生命周期和JVM相同
Java虚拟机栈
这一部分是线程私有的,每一个线程都会有一个虚拟机栈,虚拟机栈是Java方法执行的内存模型,每一个方法都在虚拟机栈中都对应一个栈帧,该栈帧存放的是,方法调用需要使用到的局部变量表(8大基本数据类型和reference对象引用)、方法参数、动态链接、操作数栈、方法出口的信息。Java没有c、c++等相关的链接过程,在class文件中,没有直接指向变量内存入口的真正地址,而是符号引用的地址,在类加载和解析的时候,才会把符号引用转化到变量在内存中真正的内存入口地址。动态链接:就是该栈帧在方法区所属方法的引用,方法区存放的是类信息。操作数栈:Java方法的执行是基于栈的解释执行,故一个方法的执行过程,参数的入栈出栈,操作的字节码指令就在操作数栈中存放。会抛出OutOfStackFlowError和OutOfMemeryError错误,生命周期和线程相同
本地方法栈
执行本地方法时的栈,有些虚拟机,比如hotspot虚拟机直接把虚拟机栈和本地方法栈合二为一了,也是线程私有的。会抛出OutOfMemeryError和OutOfStackFlowError错误。生命周期和线程相同
程序计数器
线程私有的,属于每一个线程,主要作用,因为Java多线程的实现是靠线程轮流切换来获取CPU执行时间的,同一的确定的时刻,只有一个线程中的指令会被CPU执行,一个cpu只能执行一个线程中的指令,而对于多核CPU而言,同一个时刻,只有一个核会执行一个线程中的指令。线程的恢复、异常跳转、分支、循环、字节码指令的执行都需要程序计数器,所有事线程私有的,不共享的,生命周期和线程相同。生命周期和线程相同
直接内存
不属于jvm管理的内存,JavaNIO需要使用到JVM外部内存的时候,直接向操作系统申请的内存空间,当申请失败的时候,也会抛出OutOfMemeryError错误
运行时常量池
这部分属于Java方法区的,也是线程共享的,因为整个方法区都是线程共享的。这里存放的是类的版本信息、字段信息、方法信息,还有符号引用和直接引用(类加载以后class对象的引用、符号引用因为类加载解析以后转换成的直接引用)
Java对象的在jvm中创建过程
当jvm收到一条new指令的时候,会首先去方法区中查找这条指令对应的参数(类)的符号引用是否存在、是否被jvm加载,如果没有加载,会触发类加载过程。当类被加载以后,该对象需要使用到的内存空间大小也被确定,为该对象分配内存空间有两种方式,第一种是指针碰撞(移动一块大小一样的给对象即可),第二种是空闲列表,跟采取的垃圾收集算法和收集器相关。对象在虚拟机中创建时非常频繁的,因为Java本身是纯面向对象的语言,在高并发的情况下,可能出现正在给对象A分配内存空间的时候,指针还没来得及修改,此时B线程又同时使用了该指针来分配内存的情况。Java有两种方式解决这个情况,一种是CAS配上失败重试的方式来保证内存分配更新操作的原子性。另一种是本地线程分配缓冲(在每个线程中预先分配一小块内存),即需要在那个线程中分配内存,就在那个线程的TLAB上分配,当线程的TLAB使用完毕时,才需要同步锁定,可以通过-XX:+/-UseTLAB参数来设定。内存分配完成后,虚拟机需要将分配的内存空间初始化为零值,保证对象的实例字段在Java代码中可以不赋初始值就直接使用,程序可以访问到这些类变量的零值。接下来虚拟机要对对象进行必要的设置,例如这个对象属于哪一个类的实例、如何找到类的元数据信息、对象的哈希码值、GC分代年龄等信息,这些信息存放在对象头中。这些工作完成以后,从虚拟机的角度看,一个新得对象产生了,但是从Java的角度看,这个对象才刚建立,还没有执行
对象的内存布局
对象分为三部分,对象头、实例数据、对其填充
- 对象头:对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁标志、线程持有的锁、偏向时间戳等。
对象头的另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定当前对象属于那个类的实例。 - 示例数据部分:是对象真正存储的有效信息,就是程序中使用到的类的字段信息,根据程序员
方法初始化以后的真实数据,包括从父类继承下来的,还是在子类中定义的,都需要记录起来。
对象的访问和定位
- 句柄方式:Java虚拟机栈的局部变量表中存放的reference指针指向的是对象位于堆中给句柄池分配的内存空间中的句柄池地址。 句柄池中存放的是指向对象实例数据的地址,和指向对象类元数据的地址
- 指针方式:Java虚拟机栈中的局部变量表存放的reference指针指向的就是堆中对象的地址,对象中存放了对象的实例数据和类的元数据信息。
对象引用(jdk1.2后)
- 强引用:new出来的,jvm情愿抛出内存不够,也不会清除这部分引用指向的对象
- 软引用:可以存活到第二次垃圾收集之前
- 弱引用:不是必需的,可以存活到第一次垃圾收集之前
- 虚引用:没有啥用处,只是在垃圾收集的时候,给一个通知作用。
如何确定堆中的对象是可以回收的
目前有两种方式,第一种是计数器,一个对象引用了另外一个对象,那么计数器就+1,引用断开,就-1,当计数器的值为0的时候,说明这个对象没有引用和依赖,那么就是可以被清除的,虽然这样的方法可以实现,使用起来也方便,但是确定是不能识别循环依赖的问题,这样会出现两个循环依赖的对象,没有用处了,但是也不会被系统回收。第二种就是,可达性分析,即虚拟机会把一系列称为GCROOT的对象当作起始点,从这些节点开始向下搜索,所走过的路径称为引用链,当一个对象没有任何引用链相连的时候,也就是GCROOT到该对象不可达,证明这个对象不可用,可以回收。一般能作为GCROOT的有Java虚拟机栈本地变量表中引用的对象,方法区中静态属性修饰引用的对象,方法区中常量引用的对象,本地方法栈中JNI引用的对象。对象真正的清除会经历两个阶段,第一个阶段,对象被标记为可以清除的时候,会调用finalize()方法,这个方法只会被调用一次,此时对象还不会被清除,只是会被放置到F-queue队列中,这个队列会在稍后一个由虚拟机自动建立的低优先级的线程去执行。如果此时队列中的对象可以和外部任意一个引用链上的对象建立关联,那么它就不会被回收。方法区的回收效率要低一些,因为方法区也叫永久区,存放的都是类加载以后的直接引用或者符号引用、class对象、常量、静态变量等。这些对象可能会存活很长一段时间,要判断类对象是否可以回收,需要满足一下三点,一该类没有任何实例对象,二加载该类的类加载器被回收了,三该类的java.lang.class对象没有在任何地方被引用,即无法在任何地方通过反射访问到该类的方法。此时,类才是无用的,才可以被回收。
垃圾收集算法
- 标记-清除:分为标记和清除两个过程,这个两个过程效率都不高,而且这个算法会产生大量不连续的空间,导致以后大对象需要大空间的时候,不得不再触发一次垃圾回收,但是实现简单。
- 标记-整理:和标记-清除原理相同,不同点在,不是清除,而是把存活的对象移到一边,这样可以直接清理掉边界以外的空间。
- 复制算法:把内存空间一分为二,每次只使用其中一块,需要对其中一块进行垃圾回收时,先把待清理的空间中存活的对象一次性移动到另外一块空间,然后把当前空间一次性清理掉。缺点就是可用空间只有一半,代价太高了,现代虚拟机改进了该算法,内存空间不是1:1,而是分为1:9,确切的说是,Eden:80%,剩下的20%分为两个10%的survivor区,只是使用Eden和其中的一个Survivor区,当需要垃圾回收时,把其中还存活的对象移动到剩下的一个survivor区中去,然后把空间清理掉。如果出现survivor不能装下存活的对象的时候,这时候需要老年代来保证,也就是担保机制。
- 分代收集算法:分为老年代,年轻代。新生代中对象存活率不高,可以使用复制算法,老年代因为对象存活率高、时间长,可以使用标记-清除或者标记-整理算法回收。
HotSpot算法实现
- 枚举根节点:现在很多应用仅方法区就有几百兆,如果要逐个检查这里面的引用,必然会消耗很多时间,而且可达性分析对执行时间的敏感还体现在GC停顿上,即stop the world,因为在分析引用链的时候,为了准确性,整个引用链是不能改变的,这就导致GC进行时,必须停顿所有的Java执行线程,即使在号称不会发生GC停顿的CMS收集器中,枚举根节点的时候也必须要停顿的。目前主流的Java虚拟机使用的都是准确式GC,所以当执行系统停顿的时候,并不需要一个不漏的检查完所有执行上下文和全局的引用,而是使用一个OopMap的数据结构来达到这个目的。
- 安全点:在OopMap的协助下,HotSpot可以快速完成根节点的枚举,但是又产生了一个问题,OopMap内容变化的非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外空间,这样GC成本会变高。何为安全点,就是OopMap记录的信息。为此有两种方式去很好的解决安全点的问题,一种式抢断式,一种是主动式。抢断式为在GC发生时,首先把所有的线程中断,如果发现还有线程中断的地方不在安全点上,那么就恢复线程,让它跑到安全点上,现在基本上没有虚拟机使用的是抢断式来暂停线程从而响应GC事件,主动式是当GC需要中断线程的时候,不直接对线程操作,而是简单的设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时,就自己中断挂起。
- 安全区域:安全点解决了枚举根节点的效率问题,安全点必须要线程处于活动状态(ready或者runing)如果线程处于block或者wait时,线程不能响应jvm的中断请求,到安全点去中断挂起,此时推出了安全区域来解决这个问题,一段代码中,引用关系不会发生改变,在这个区域的任意地方GC都是安全的,可以把SafeRegion看做是被扩展了的SafePoint。
垃圾收集器
- Serial收集器:Client模式下的单线程的默认收集器
- ParNew收集器:Serial的多线程版本
- Parallel Scavenge收集器:新生代使用复制算法的收集器
- Serial Old收集器:Serial收集器的老年代收集器
- Parallel Old收集器:Parallel Scavenge收集器的老年代收集版本
- CMS收集器:最短回收停顿的收集器,web应用常用的
- G1收集器:当前垃圾收集器的前言成果,但是还不完善,在持续完善中
Java内存模型
- 工作内存:Java对变量的所有操作,都在工作内存中进行。
- 主内存:变量和实例对象都在主内存中诞生。
工作内存为于线程中,并且各个线程之间不共线是透明的。Java如何实现变量在各个线程中可见的呢,通过主内存,每个线程在本线程中对主内存中的变量副本值做了修改或者操作以后,会通过Java内存见的操作协议同步会主内存。通过这样的方式来实现线程间的变量的可见性的。
Java内存间(工作内存和主内存)的操作协议(对比物理计算机解决缓存一致性的MESI协议)
- lock:把主内存中一个变量标识为线程独占状态
- read:把主内存中一个变量读取到线程的工作内存中
- load:把从主内存中读取到的变量写入工作内存中的变量副本中
- use:把变量值传给执行引擎使用,当虚拟机遇到需要一条使用变量的字节码指令的时候,就会执行一次
- assgin:把从执行引擎中返回的值存储到工作内存中,当虚拟机遇到需要给一个变量赋值时候会执行一次
- store:把工作内存中变量的值传回主内存
- write:把从工作内存发起写回的变量值写回到主内存中的变量中去
- unlock:把主内存中一个标识为线程独占状态的变量解锁,只有解锁后的变量才能被其他线程锁定
以上8个操作都是原子性的,不可再分割,也不会因为线程调度和切换而被中断
与上边8个操作协议对应的,jvm规定执行上述8个操作时还必须满足如下的操作规则
- read、load和store、write必须成对出现,即发起从主内存读取变量值的请求,而工作内存不接受,或者发起从工作内存写回的请求,而主内存不接受的情况。
- 线程不能丢弃最近的assgin操作,也就是变量值得修改,必须写回主内存
- 不允许一个线程读取了变量值,但是不做任何修改(assgin操作)就写回主内存
- 同一时刻一个变量只能被一个线程锁定,但是可以被一个线程多次锁定,多次锁定时必须解锁相同的次数,其他线程才可以使用。
- 不能去unlock被其他线程锁定的变量
- lock一个主内存中的变量时,必须清空当前锁定线程中的工作内存中的此变量的副本值,然后重新从主内存中读取此变量的值
- unlock一个变量时,必须先把当前发起unlock操作的线程中的工作内存中的主内存中变量的副本值写回主内存
- 一个变量只能在主内存中诞生,不允许在线程的工作内存中使用一个没有初始化(赋初始值)的变量。这就是为什么局部变量必须初始化的原因。
以上的内存间操作协议和必须满足的8个操作规则,就可以确定在高并发的情况下,那些内存操作是线程安全的。但是这样去判断,过程很繁琐麻烦,于是出现了一个等效替换原则,先行发生(happens-before)原则来简化这个判断流程
先行发生(happens-before)原则
- 程序次序规则:在一个线程中,注意是一个线程,即单线程模式下,前边的代码先行发生于后边的代码,当然需要根据流程控制来判断。
- 管程锁定规则:对同一把锁来说,unlock操作先行发生于lock操作,记住是同一把锁。
- volatile规则:对一个被volatile修饰的变量的写操作先行发生于对此变量的读操作。
- 线程启动规则:Thead对象的start()方法先行发生于此线程的所有动作。
- 线程中断规则:线程的中断方法调用(interrupt())先行发生于此线程检测到中断发生。
- 线程结束规则:线程中的所有方法先行发生于此线程的stop()方法,即先行发生于终止检测。
- 对象终结规则:对象的init()方法先行发生于此对象的finalize()方法。
- 传递性规则:操作A先行发生于操作B,操作B先行发生于操作C,那么A先行发生于C。
volatile关键字
- 变量可见性:对被volatile修饰的变量的操作,都是能及时同步回主内存的,没有延迟,就像在主内存中操作变量一样。
- 禁止指令重排序:对被volatile修饰的变量操作时,JIT即时编译器的优化,不能把volatile修饰的变量放置到后边执行,也就是说该变量在什么位置,那么它一定会在此位置之前或者就在此位置被执行,不会因为指令重排序到后边执行,所有依赖改变了值得地方,都能得到正确得结果,如果对volatile修饰得变量得操作不是原子性的,也会出现变量值过期的问题。禁止指令重排序的字节码实现是,加了内存屏障,在该屏障后的操作会无效其cpu中cache,重而导致一次新的read、load过程,通过这样的方式达到可见性。
Java中的可见性、原子性、有序性
- 原子性:Java中的基本数据类型的操作都是原子性的,原子性即不可再分,不会因为线程切换而停止,cpu必须执行完一个原子操作,才能让出cpu执行时间,去执行其他线程中的字节码指令。Java中实现实现原子性的关键字是synchronized。
- 可见性:即工作内存中变量值的修改,能即时同步回主内存。Java通过final、synchronized、volatile关键字实现。
- 有序性:在单线程中表现为串行,多线程中都是无序的,指令重排序(物理计算机的乱序优化)还有因为工作内存和主内存同步的延迟造成的。
Java高并发下的线程安全
在高并发的情况下,如何实现线程安全,jvm有自己的方式。在Java中或者jvm中,多线程的实现是通过线程轮流切换来获取CPU执行时间完成的,在同一时刻一个CPU中只能有一个线程中的字节码指令会被cpu执行。线程安全的定义:一般来说,一个对象无论使用怎样的方式去调用,不管什么顺序去调用,不管是多个线程去调用,都不会出现不安全的或者脏数据的情况,那么我们就说这个对象在多线程下是线程安全的。
- 不可变类:不可变类是天然线程安全的,一个不可变类或者所有的状态变量都被final修饰,那么当这个类的对象被正确创建以后,都不能在发生改变了,那么此对象就是线程安全的。具体实现可以参考Java.lang.String类和枚举类,以及Number的部分子类的实现。
- 无状态类:没有状态的类的对象,也就是说这个对象只有行为(方法)所有操作的数据都是从参数中传递过来的,此时这个变量是封装在线程中,或者Java虚拟机栈中的,对其他线程是透明的,其他线程访问不到这个变量,自然也不会出现线程安全的问题,那么也是线程安全的。
- 同步阻塞:通过synchronized关键字或者ReentrantLock来实现,Synchronized只能修饰方法,不能修饰类和字段。Synchronized的底层是通过monitorenter和monitorexit两个字节码指令来实现的,是jvm原生层面的支持,ReentrantLock则是JavaApi实现的,底层是通过CAS+park+自旋和阻塞队列实现的。同步阻塞的原理是,在进入共享资源访问的临界区,需要获取到锁对象,synchronized关键修饰的对象,就是锁对象,如果没有指定,那么就看修饰的是实例对象还是类对象,如果是实例对象就可以对应的实例,反之就是获取类对象,只有获取到锁的,才能进入临界区,对受保护的共享资源的访问结束以后,离开临界区时,需要归还锁对象,其他线程才可以竞争获取这把锁。
- 乐观CAS:需要硬件的支持,jdk1.5以后,随着硬件指令集的发展,可以失败重试的机制来完成对共享资源的原子访问操作。
- 线程本地化:Thradlocal、数据库连接池、生产者消费者队列、web应用(一个请求对应一个服务器线程)等等。
- 可重入代码:即在方法中不访问类变量,即随时可以暂停执行的代码,恢复以后不影响结果的,传入相同的参数可以得到一样的结果的,递归,纯方法。
锁的优化
- 自旋和自适应自旋:这个跟Java线程的创建和调度有关,Java线程的创建依赖于操作系统,线程调度使用的是抢占式。线程的切换需要在内核态(kernel)和用户态中来回切换,这样很浪费时间和性能,为了解决这个出现了,当前线程不是真的让出CPU执行时间,而是先自旋几次,如果自旋几次以后,仍热不行,那么就真的让出CPU执行时间。自适应自旋原理差不多,不同的是自旋次数而已。
- 锁消除:通过逃逸分析技术,JIT在优化时,发现变量不会逃逸出当前线程,或者不会逃逸出当前方法,那么加锁的方法可以去掉锁,因为锁很耗时,对性能影响较大。
- 锁粗粒化:例如在一个循环中加锁,会被JIT优化到,对整个方法加锁,而不是循环中加锁。
- 轻量级锁:synchronized属于重量级锁,轻易不适用,轻量级锁,也叫乐观锁CAS,本意是在没有多线程竞争的前提下减少传统重量级锁使用操作系统互斥量产生的性能损耗。记住,轻量级锁并不是取代重量级锁的。该操作需要依赖对象头中的锁标志位。
- 偏向锁:在无竞争情况下的同步原语,在无竞争的情况下,连CAS都可以去掉。
Java类文件结构
- 魔方数
- 次版本号
- 主版本号
- 常量池:各种符号引用
- 访问限定符
- 类索引
- 父类索引
- 接口索引集合
- 字段集合
- 方法集合
- 属性表集合
Java类加载过程
- 加载:通过类的全限定名获取定义的此类的二进制文件字节流、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构、在内存中生成一个代表该类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
- 验证:文件格式验证、字节码验证、元数据验证、符号引用验证。
- 准备:类的静态字段初始化零值,如果没有静态字段,可以不生成类的clinit方法
- 解析:将常量池中的符号引用转化成直接引用的过程,Java没有c、c++等语言的连接步骤,class文件中没有存储内存布局信息,部分需要在类加载的时候动态解析。类解析、字段解析、类方法解析、接口方法解析,类的静态方法、构造方法、父类方法、私有方法是可以确定的,不会因为Java的多态而出现不同的调用版本,所以可以在类加载的解析阶段完成方法的解析,对象的实例方法需要在调用的时候才能确定真正的执行版本。
- 初始化:这个步骤是真的按照程序员的意志初始化对象的步骤。
- 使用:使用对象,完成功能。
- 卸载:使用完毕以后,回收对象,卸载类。
Java方法调用过程
面向对象的三大特征:封装、继承、多态。Java是一门纯面向对象的语言,自然也是支持多态的,Java的多表现为:方法重载(overload)、方法覆写(override),Java方法的分派可以分为静态、和动态,根据宗量数的不同,Java方法的分派可以分为单分派和多分派,两两组合即静态单分派,静态多分派,动态单分派,动态多分派。静态分派:根据方法签名(方法参数类型、顺序、方法名等)和方法接收者确定调用的那个方法,动态分派:当覆写了父类方法时,jvm在调用方法时,会根据Java虚拟机栈顶对象的真实数据类型来确定调用父类的方法还是子类的方法。
早期优化
编译时的优化
- javac编译器:解析和填充符号表
- 注解处理器
- 语义分析于字节码生成
- Java语法糖:泛型于类型擦除、自动装箱、拆箱于遍历循环、条件编译
晚期优化
运行时的优化
- 公共子表达式的消除、数组边界检查的消除、方法内联优化、逃逸分析