锁
在Java程序运行时环境中,JVM需要对两类线程共享的数据进行协调:
1)保存在堆中的实例变量
2)保存在方法区中的类变量
这两类数据是被所有线程共享的。
死锁
产生死锁 4 个必要条件
- 互斥条件
一个资源每次只能被一个进程使用 - 保持和等待条件
一个进程因请求资源而阻塞时,对已获得的资源保持不放 - 不剥夺条件
进程已获得的资源,在未使用完之前,不能强行剥夺 - 环路等待条件
若干进程之间形成一种头尾相接的循环等待资源关系
示例
解除死锁的方法
破坏 4
个条件同时成立:
A.进程互斥,m<nw时可能会出现死锁,其中m表示资源总数,n表示互斥进程数,w表示每个进程需要的资源数
B.在m<nw时,避免死锁的方法,先均匀分配1个资源,剩下的全都分给某一个进程,确保这个进程能获取足够多的资源完成并退出。
C. 也就是说B中是理想避免死锁的方法,但还是可能出现死锁,比如剩下部分并没有全部分给单个进程,而是再次均匀分配就出现死锁了。
D. 当两个进程都在请求资源,但是没有资源可用时,则称这两个进程为阻塞节点,资源图称为是不可以简化的、是死锁的。
volatile 关键字
synchronize 关键字
synchronized的字节码表示:synchronized关键字及实现细节
在 Java
语言中存在两种内建的 synchronized
语法:
synchronized
语句
对于synchronized
语句当Java
源代码被javac
编译成bytecode
的时候,会在同步块的入口位置和退出位置分别插入monitorenter
和monitorexit
字节码指令synchronized
方法
而synchronized
方法则会被翻译成普通的方法调用和返回指令。如:invokevirtual、areturn
指令,在JVM
字节码层面并没有任何特别的指令来实现被synchronized
修饰的方法,而是在Class
文件的方法表中将该方法的access_flags
字段中的synchronized
标志位置 1 ,表示该方法是同步方法并使用调用该方法的对象或该方法所属的Class
在JVM
的内部对象表示Klass
做为锁对象。
并发主要关注那些问题
线程安全,内存管理及垃圾回收,java并发
JVM 内存模型及多线程并发
原子性,可见性,有序性
Java
运行时数据区域
介绍
程序计数器、虚拟机栈,本地方法栈的生命周期都和当前线程保持一致,这三块区域属于当前线程私有的。而方法区、堆、常量池是所有线程共享的数据区
程序计数器 The pc Register
Java
虚拟机栈 Java Virtual Machine Stacks
每个方法在执行时都会创建一个栈帧存储:局部变量表、操作数栈、动态链接、方法返回地址等。常说的 Java
内存分为堆内存 Heap
和栈内存 Stack
,这里的栈指的就是虚拟机栈,或者说是虚拟机栈中的局部变量表部分。
- 局部变量表
存放了编译期可知的各种基本数据类型,对象引用(reference
类型)和returnAddress
类型(指向一条字节码指令的地址) - 基本数据类型
boolean, byte, char, short, int, float, long, double
- 存储空间
其中64
位长度的long
和double
类型的数据会占用2
个局部变量空间,其余的数据类型只占用1
个(比如 32 位虚拟机,一个变量空间占用32位)。根据long
和double
的非原子性协议,把这两个数据类型分割为两次 32 位读写操作(即需求读取两次)。不过局部变量是线程私有数据,即使两次读写也不会引起数据安全问题。
本地方法栈 Native Method Stacks
堆 Heap
所有对象的实例及数组都是在堆上分配的。
方法区 Method Area
别名:Non Heap
,
运行时常量池 Run-Time Constant Pool
异常
StackOverFlowError
如果现场请求的栈深度大于虚拟机所允许的深度,将抛出该异常
虚拟机栈和本地方法栈区域可能会抛出该异常OutOfMemoryError
如果虚拟机栈可以动态扩展,扩展时无法申请到足够的内存,将抛出该异常
只有程序计数器中不会抛出OutOfMemory
,其他数据区域都可能会抛出
引用
Java
引用分为:强引用、软引用、弱引用、虚引用。这 4 种引用强度一次逐渐减弱。垃圾回收时会根据引用类型来决定是否回收这些内存
强引用
Strong Reference
最常见的应用:Object obj = new Object()
,这类引用就是强引用,也就是我们一般声明对象是时虚拟机生成的引用。垃圾回收时需要严格判断当前对象是否被强引用,只要强引用还存在,则不会被垃圾回收。软引用
Soft Reference
使用SoftReference
类来实现软引用,用来描述有用但并非必需的对象。对于软引用关联着的对象在垃圾回收时,虚拟机会根据当前系统的剩余内存来决定是否对软引用进行回收。如果剩余内存比较紧张,则虚拟机会回收软引用所引用的空间;如果剩余内存相对富裕,则不会进行回收。在系统要发生内存溢出异常之前,会将这些对象进行回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。换句话说,虚拟机在发生OutOfMemory
时,肯定是没有软引用存在的。弱引用
Weak Reference
使用WeakReference
类来实现弱引用,用来描述非必需对象,但是强度比软引用更弱一些。被弱引用关联的对象只能生存到下一次垃圾收集发生之前,当垃圾回收发生时,无论当前内存是否足够,被弱引用关联的对象都会被回收,因此其生命周期只存在于一个垃圾回收周期内。虚引用
Phantom Reference
使用PhantomReference
类来实现虚引用,唯一目的是对象在被回收时能收到一个系统通知。也称为幽灵引用或者幻影引用,是最弱的一种引用关系。虚引用完全不会影响对象的生存时间,也无法通过虚引用来取得对象的实例总结
软引用:当虚拟机内存不足时,会回收它指向的对象
弱引用:随时可能会被垃圾回收器回收,不一定要等到虚拟机内存不足时才强制回收
软引用多用作来实现缓存机制(cache
),而弱引用一般用来防止内存泄漏,要保证内存被虚拟机回收
类加载
Class
文件的魔数
魔数为: 0xCAFEBABE
,即 Class
文件的前 4 个字节固定为这串数字
65536 方法数
常量池包含 14 种常量类型(每个都有各自的数据结构),其中包含一个 CONSANT_Utf8_info
类型的常量: u1 type; u2 length; u1 bytes
。Class
文件中的方法,字段等都是该常量来描述的,所有该常量的最大长度也就是 Java
中方法、字段名的最大长度。length
的最大值即为 u2
类型能表达的最大值 65536
。所以 Java
程序如果定义了超过 64KB
英文字符的变量、方法名、方法数等,将无法编译
类加载前的初始化
有且只有 5 种情况必须对类进行初始化
- 遇到
new, getstatic, putstatic, invokestatic
这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这几条指令的场景为:
使用new
关键字实例化对象;读取或设置类的静态字段(但是被final
或者进入常量池的静态字段除外);调用一个类的静态方法 - 使用
java.lang.reflect
包的方法对类进行反射调用的时候,如果类没有进行过初始化,需要先触发初始化 - 初始化一个类的时候,如果发现父类还没有初始化,先触发父类初始化
- 用户指定要执行额主类:即包含
main
主类的先初始化 - 如果
java.lang.invoke.MethodHandle
实例最后解析的结果为REF_getStatic, REF_putStatic, REF_invokeStatic
的方法句柄,如果没有初始化会触发其初始化
类变量
类中被 static
修饰的变量,会被赋值两次。
- 类加载准备阶段
准备阶段赋值为系统初始值,如:int=0;reference=null
- 类加载初始化阶段
初始化阶段赋值为执行Java
语句的赋值,即类变量真实的值
类加载器
类的加载器的 3 种分类
- 启动类加载器
Bootstrap ClassLoader
这个是C++
实现,虚拟机的一部分,负责将JAVA_HOME\lib
目录下的类库加载到虚拟机内存中,无法被用户直接使用 - 扩展类加载器
Extension ClassLoader
由sun.misc.Launcher$ExtClassLoader
实现,负责加载JAVA_HOME\lib\ext
中的类库,用户可以直接使用这些扩展类加载器 - 应用程序类加载器
Application ClassLoader
由sun.misc.Launcher$AppClassLoader
实现,负责加载CLASSPATH
中的类库。这个类加载器是ClassLoader.getSystemClassLoader()
的返回值,所以一般称为系统类加载器,用户可以直接使用
双亲委派模型 Parents Delegation Model
上图所示的类加载器之间的层次关系,称为类加载器的双亲委派模型。该模型要求除了顶层启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里的父子关系不是继承关系,而是组合关系来复用父类代码。
双亲委派模型原则:某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。
方法调用:分派
方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(具体调用哪个方法)。Class
文件的编译过程中不包含链接步骤,所有的方法调用在 Class
文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址。方法调用在类加载期间,甚至运行期间才能确定目标方法的入口地址(直接引用)。
变量静态类型和实际类型
先看一段代码:Human man = new Man()
- 静态类型
Static Type
其中Human
称为变量的静态类型,或者叫外观类型Apparent Type
。静态类型仅仅在使用时有可能会出现改变,但在编译期能够明确最终的静态类型 - 实际类型
Actual Type
其中Man
称为变量的实际类型。实际类型在运行期才能确定,编译器无法判定对象的实际类型是什么 - 示例
1
2
3
4
5
6// 实际类型变化,运行时才能确定
Human man = new Man();
man = new Woman();
// 静态类型变化,使用时就确定了
sr.sayHello((Man) man);
sr.sayHello((Woman) man); - 引申
根据内存模型,man
是存储在虚拟栈中,属于局部变量表中的引用变量;而new Man()
表示在堆中分配一块存储区,引用变量的值为指向这块堆的指针
重载与重写及分派
- 重载
编译器在编译阶段通过参数的静态类型来作为重载的判断依据:
1 | public void sayHello(Human guy){...} |
- 重写
只有在运行时根据实际类型,虚拟机才能确定调用哪个重写的方法
1 | class Man extends Human{ |
- 静态分派
依赖静态类型来定位方法具体执行哪个版本(重载时),这个分派动作称为静态分派。典型应用:重载。在静态分派过程中,如果没有指定显示的静态类型,会发生类型的自动转换来匹配最可能的类型, 如:sayHello('a')
,如果没有明确重载sayHello(char c)
方法,会按照 :char -> int -> long -> float -> double ->Character ->Serializable --> Object
的顺序转型。其中: char
转为int
: 表示a
除了代表字符串,还可以代表数字 97char
转为它的封装类型Character
:是一次自动装箱过程char
转为Serializable
是因为Character
实现了序列化和可比较
但是如果同时重载了sayHello(Serializable s)
和sayHello(Comparable c)
会提示类型模糊,编译报错- 动态分派
在运行期根据实际类型确定方法执行哪个版本(重写),这个分派过程称为动态分派
泛型
本质是参数化类型 Parametersized Type
的应用,也就是说操作的数据类型被指定为一个参数。这种参数类型可以用在类、接口和方法的创建中,分别被称为泛型类、泛型接口和泛型方法。Java
的泛型只在源码中存在,在编译后的字节码文件中都会被替换为原生类型 Raw Type
,并且在相应的地方插入了强制转型代码。 Java
语言泛型的实现方法称为类型擦除(Type Erasure
),这种实现也被称为伪泛型。
1 | // 源码 |
特征签名
方法特征签名:仅仅包括方法名称、参数类型以及参数顺序。(不包含返回值)
Class
文件格式中方法表
包含的几个重要字段:名称、描述符、属性组等
名称
全限定名:比如com/google/dagger/DaggerClass;
是这个类的全限定名,仅仅是把类全名的.
替换为/
而已,再以分号结束
简单名称:指没有类型和参数修饰的方法或者字段名称,比如这个类中的inc()
方法和m
字段,简单名称分别为inc
和m
Class
文件格式方法表中的name_index
指的就是简单名称描述符
描述符的作用用来描述字段的数据类型、方法的参数列表(包含数量、类型和顺序)和返回值。根据描述符规则:基本数据类型和无返回值的Void
类型都用首字母大写来表示;对象类型使用字符L
加对象的全限定名来表示;数组类型每一个维度使用一个前置[
字符来描述。示例:
1 | int[] --> [I |
只有描述符不一致的两个方法才能再同一个
Class
文件中共存
属性组
每个方法可以有任意个与之相关的属性(20 个)Sigature
属性:
在Java
语言中,任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量或者参数化类型,则Signature
属性会记录泛型签名信息。所以类型擦除仅仅是对方法的字节码进行了擦除,实际上Signature
属性保留了泛型信息
泛型重载
- 源码
方法mytest
重载,参数类型都是泛型List<E>
1 | import java.util.List; |
- 编译
根据特征签名的定义(不包含返回值),以及泛型的类型擦除特性,mytest
特征签名是一样的,所以在编译过程中报错:name clash: mytest(List<String>) and mytest(List<Integer>) have the same erasure
1 | // java 7及以上不能编译通过 |
其中 Java 6
能通过编译的原因,是因为 class
文件只需要描述符(返回值不同)不一致就可以共存,并且 Singture
属性保留了泛型信息,所以能正常编译和运行。但是 Java 7
开始,从编译开始就检查泛型的特征签名,所以无法编译通过
参考: Java 泛型重载 jdk 1.7,描述符与特征签名
自动装箱和拆箱 (Boxing and Unboxing Conversation)
基本类型自动转换为对应的封装类型(对象)即为自动装箱(Auto Boxing Conversation
);反之,封装类型自动转换为基本类型为拆箱(Unboxing Conversation
)
类型及转换
对应类型
boolean, byte, short, char, int, long, float, double
对应的封装类型:Boolean, Byte, Short, Character, Integer, Long, Float, Double
转换过程
装箱:*.valueOf(*)
拆箱:*.intValue()
源码如下,展示了常见的自动装箱和拆箱的用法
1 | // 自动装箱 |
经过反编译后,可以明确看到装箱和拆箱的动作:
1 | Integer localInteger = Integer.valueOf(1); // 装箱 |
自动装箱后的比较
因为是对象比较,所以建议直接使用
equal
而不是==
==
比较的是对象的首地址。但是 Java
做了部分优化,在如下范围内使用的是相同的对象(封装类型的缓存),范围外则在装箱的过程中重新生成一个对象。
- char
[\u0000, \u007f]
:即ASCII
码表中的 128 个字符 - byte/short/int/long
[-128, 128)
:在-128 <= x < 128
范围内,共用相同的对象(缓存),即byte
能够表达的范围 - float/double
没有缓存,直接重新生成一个新对象 - 示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// char
Character ca = '\u007f', cb = '\u007f';
Character cc = '\u0080', cd = '\u0080';
System.out.println(ca == cb); //ture
System.out.println(cc == cd); //false
// byte, short, int, long: [-128, 128)
Short sa = -128, sb = -128;
Short sc = -129, sd = -129;
Integer ia = 1, ib = 1;
Integer ic = 128, id = 128;
Long la = 1L, lb = 1L;
Long lc = 128L, ld = 128L;
System.out.println(sa == sb); //true
System.out.println(sc == sd); //fasle
System.out.println(ia == ib); //true
System.out.println(ic == id); //fasle
System.out.println(la == lb); //true
System.out.println(lc == ld); //false
String
的自动拆装箱
概念:Java
中的 "abc"
对应的实际是常量,存储在常量池中
1 | // 对应的都是常量池中 "abc" 的地址 |
注意事项
- 内存空间
自动装箱涉及到重新生成对象,所以频繁大量的使用会创建很多无用的对象,增加GC
压力,拉低程序的性能 - 拆箱空指针异常
如果封装类型并没有初始化,在拆箱时会报空指针异常,因为编译过程无法检测到,只能在运行时出现,需要特别注意
内存泄露和内存溢出
- 内存泄露
Memory Leak
内存泄露是对象在内存中还活着占用内存,但是无法被垃圾回收。指对象到GC Roots
的引用链 - 内存溢出
Memory Overflow
无法申请到足够的内存
垃圾回收
finalize
方法名。Java 技术允许使用 finalize() 方法在垃圾收集器将对象从内存中清除出去之前做必要的清理工作。这个方法是由垃圾收集器在确定这个对象没有被引用时对这个对象调用的。它是在 Object 类中定义的,因此所有的类都继承了它。子类覆盖 finalize() 方法以整理系统资源或者执行其他清理工作。finalize() 方法是在垃圾收集器删除对象之前对这个对象调用的。
Java中所有类都从Object类中继承finalize()方法。
当垃圾回收器(garbage colector)决定回收某对象时,就会运行该对象的finalize()方法。值得C++程序员注意的是,finalize()方法并不能等同与析构函数。Java中是没有析构函数的。C++的析构函数是在对象消亡时运行的。由于C++没有垃圾回收,对象空间手动回收,所以一旦对象用不到时,程序员就应当把它delete()掉。所以析构函数中经常做一些文件保存之类的收尾工作。但是在Java中很不幸,如果内存总是充足的,那么垃圾回收可能永远不会进行,也就是说filalize()可能永远不被执行,显然指望它做收尾工作是靠不住的。
那么finalize()究竟是做什么的呢?它最主要的用途是回收特殊渠道申请的内存。Java程序有垃圾回收器,所以一般情况下内存问题不用程序员操心。但有一种JNI(Java Native Interface)调用non-Java程序(C或C++),finalize()的工作就是回收这部分的内存。
常见问题
多线程并发时,提到 long
和 double
类型避免脏数据,需要使用 volatile
修饰?
long
和 double
的非原子协定:Java
的内存模型要求,变量的读取操作和写入操作都必须是原子操作的,但是对于非 volatile
类型的 long, double
有些不同,因为这两个变量是 64 位存储,JVM
允许将 64 位的读操作或写操作分解为 2 个 32 位的操作。这样,当在多线程环境中读取一个非 volatile
的 long, double
变量时,可能会出现读取到这个变量一个值的高 32 位和另一个值的低 32 位,从而导致数据出问题。
但是商用虚拟机基本不会出现这种情况,可以不用过于担心。
多线程并发时需锁住内存模型中的哪一块数据?
并发只影响共享变量:堆内存或方法全局变量。其他线程独享的数据不必关心,比如方法内部变量等等
图书
- Java多线程编程核心技术
- 深入理解Java虚拟机:JVM高级特性与最佳实践 第2版
- Java 规范官方文档
包含语言规范和 JVM 规范 - java并发编程的艺术