2021秋招面试计算机基础总结 - Java基础, JVM, Spring框架相关
Java
Java 平台无关性
主要通过三个方面实现.
- Java语言规范: 通过规定Java语言中基本数据类型的取值范围和行为,比如int长度为4字节,这是固定的。
- Class文件: 所有Java文件要通过javac或者其他一些java编译器编译成统一的Class文件
- Java虚拟机:
- 通过Java虚拟机将字节码文件(.Class)转成对应平台的二进制文件
- JVM是平台相关的,需要在不同操作系统安装对应的虚拟机
程序运行过程
- 编译:java源文件编译为class字节码文件
- 类加载:类加载器把字节码加载到虚拟机的方法区。
- 运行时创建对象
- 方法调用,JVM执行引擎解释为机器码
- CPU执行指令
- 多线程切换上下文
Java 三大特性
- 封装:将一系列的操作和数据组合在一个包中,使用者调用这个包的时候不必了解包中具体的方法是如何实现的。
- 多态:父类的变量可以引用一个子类的对象,在运行时通过动态绑定来决定调用方法。
作用:- 应用程序不必为每一个派生类编写功能调用,只需要对抽象基类进行处理即可。大大提高程序的可复用性。//继承
- 派生类的功能可以被基类的方法或引用变量所调用,这叫向后兼容,可以提高可扩充性和可维护性。
- 继承:一个类可以扩展出一个子类,子类可以继承父类的属性和方法,也可以添加自己的成员变量和方法。接口可以多继承,普通类只能单继承。
重载和重写
- 重写:子类具有和父类方法名和参数列表都相同的方法,返回值要不大于父类方法的返回值,抛出的异常要不大于父类抛出的异常,方法修饰符可见性要不小于父类。运行时多态。
- 是运行时多态,因为程序运行时,会从调用方法的类中根据继承关系逐级往上寻找该方法,这是在运行时才能进行的。
- 重载:同一个类中具有方法名相同但参数列表不同的方法,返回值不做要求。编译时多态。
Integer 和 int 区别
- Integer是int的包装类,所表示的变量是一个对象;而int所表示的变量是基本数据类型
- 自动装箱(valueOf)指的是将基本数据类型包装为一个包装类对象,自动拆箱(intValue)指的是将一个包装类对象转换为一个基本数据类型。
- 包装类的比较使用equals,是对象间的比较
基本数据类型
- byte 1字节;short 2字节
- int, float 4字节
- long, double 8字节
- boolean 单独出现时4字节,数组时单个元素1字节
- char 英文都是1字节,GBK中文2字节,UTF-8中文3字节
值传递和引用传递
- 值传递对基本数据类型而言的,传递的是变量值的一个副本,改变副本不影响原变量的值
- 引用传递对于对象型变量而言,传递的是对象地址的副本,不是原变量本身,所以对引用对象的操作会改变原变量的值。
== 和 equals 区别
- == 比较的对象如果是基本数据类型,就是两者的值进行比较;如果是引用对象的比较,是判断对象的地址值是否相同
- equals 如果比较的是String对象,就是判断字符串的值是否相同;如果比较的是Object对象,比较的是引用的地址内存;可以通过重写equals方法来自定义比较规则,也需要同时重写hashCode方法
方法修饰符可见类型
public: 对本包和不同包都是可见的
protected: 对不同包不可见
default: 只对本包中子类和本类可见
private:只对本类可见
Object类,equals 和 hashCode
Object 的类是所有类的父类。equals,hashCode,toString方法
- equals用来比较对象地址值是否相同
- hashCode返回由对象地址计算得出的一个哈希值
- 两者要同时重写的原因
- 使用hashcode方法提前校验,通过hasCode比较比较快,可以避免每一次比对都调用equals方法,提高效率
- 保证是同一个对象,如果重写了equals方法,而没有重写hashCode方法,会出现equals比较时相等的对象,hashCode不相等的情况,重写hashcode方法就是为了避免这种情况的出现。
- 哈希值相同的对象equals比较不一定相等,存在两个对象计算得到hashCode相等的情况,这是哈希冲突。
避免哈希冲突?
- 哈希表的特点:关键字在表中位置和它之间存在一种确定的关系。
- 解决哈希冲突:
- 开放定址法(线性探测再散列,二次探测再散列,伪随机探测再散列)
- 线性探测再散列:放入元素,如果发生冲突,就往后找没有元素的位置;
- 平方探测再散列:如果发生冲突,放到(冲突+1平方)的位置,如果还发生冲突,就放到(冲突-1平方)的位置;如果还有人就放到(冲突+2平方)的位置,以此类推,要是负数就倒序数。
- 随机探测再散列
- 链地址法:如果发生冲突,就继续往前一个元素上链接,形成一个链表,Java的hashmap就是这种方法。
- 再哈希:用另一个方法再次进行一个哈希运算
- 建立一个公共溢出区:将哈希表分为基本表和溢出表两部分,范式和基本表发生冲突的元素,一律填入溢出表。
- 开放定址法(线性探测再散列,二次探测再散列,伪随机探测再散列)
深拷贝,浅拷贝
clone:clone 方法声明为 protected,类只能通过该方法克隆它自己的对象,如果希望其他类也能调用该方法必须定义该方法为 public。如果一个对象的类没有实现 Cloneable 接口,该对象调用 clone 方法会抛出一个 CloneNotSupport 异常。默认的 clone 方法是浅拷贝,一般重写 clone 方法需要实现 Cloneable 接口并指定访问修饰符为 public。
浅拷贝:重新在堆中创建内存,将对象进行拷贝,拷贝前后对象的基本数据类型互不影响,但拷贝前后对象的引用类型因为共享同一块内存,会相互影响。(被浅拷贝的对象是会重新生成一个新的对象,新的对象和原来的对象是没有任何关系的,)如果对象中的某个属性是引用类型的话,那么该属性对应的对象是不会重新生成的,浅拷贝只会重新当前拷贝的对象,并不会重新生成其属性引用的对象。
深拷贝:从堆内存中开辟一个新的区域存放新对象,会把拷贝的对象和其属性引用的对象都重新生成新的对象。
- 实现:对拷贝的对象中所引用的数据类型再进行以拷贝;使用序列化
内部类
使用内部类主要有两个原因:内部类可以对同一个包中的其他类隐藏。内部类方法可以访问定义这个内部类的作用域中的数据,包括原本私有的数据。内部类是一个编译器现象,与虚拟机无关。编译器会把内部类转换成常规的类文件,用美元符号 $ 分隔外部类名与内部类名,而虚拟机对此一无所知。
静态内部类:由static修饰,属于外部类本身,只加载一次。类可以定义的成分静态内部类都可以定义,可以访问外部类的静态变量和方法,通过 new 外部类.内部类构造器
来创建对象。只要内部类不需要访问外部类对象,就应该使用静态内部类。
成员内部类:属于外部类的每个对象,随对象一起加载。不可以定义静态成员和方法,可以访问外部类的所有内容,通过 new 外部类构造器.new 内部类构造器
来创建对象。
局部内部类:定义在方法、构造器、代码块、循环中。不能声明访问修饰符,只能定义实例成员变量和实例方法,作用范围仅在声明这个局部类的代码块中。
匿名内部类:没有名字的局部内部类,可以简化代码,匿名内部类会立即创建一个匿名内部类的对象返回,对象类型相当于当前 new 的类的子类类型。匿名内部类一般用于实现事件监听器和其他回调。
类初始化的顺序
- 父类静态变量和静态代码块
- 子类静态变量和静态代码块
- 父类普通变量和代码块
- 父类构造器
- 子类普通变量和代码块
- 子类构造器
Comparable 和 Comparator 的区别
- Comparable和Comparator都是用来实现集合中元素的比较、排序的。
- Comparable是在集合内部定义的方法实现的排序,位于java.util下;Comparator是在集合外部实现的排序,位于java.lang下。
- Comparable是一个对象本身就已经支持自比较所需要实现的接口,如String、Integer自己就实现了Comparable接口,可完成比较大小操作。
- Comparator是一个专用的比较器,当这个对象不支持自比较或者自比较函数不能满足要求时,可写一个比较器来完成两个对象之间大小的比较。Comparator体现了一种策略模式(strategy design pattern),就是不改变对象自身,而用一个策略对象(strategy object)来改变它的行为。
- Comparable是自己完成比较,Comparator是通过外部重写比较规则实现比较。
String,StringBuilder,StringBuffer
String
- 一旦被创建就不可被修改,所以修改String变量值的时候是新建了一个String对象,赋值给原变量引用
- 两种创建方法
- 直接赋值一个字符串,就是将字符串放进常量池,位于栈中的变量直接引用常量池中的字符串。
- new 方式创建先在堆中创建String对象,再去常量池中查找是否有赋值的字符串常量,找到了就直接使用,没找到就开辟空间存字符串。通过变量引用对象,对象引用字符串的形式创建。
StringBuilder & StringBuffer
- 都继承自AbstractStringBuilder类,是可变类(这是加分项)
- 前者线程不安全,后者通过synchronized锁保证线程安全
- 因此StringBuilder执行效率高,StringBuffer执行效率低
final 关键字
- 所修饰的变量,是基本数据类型则值不能改变,访问时会被当做一个常量;是引用型变量的话,初始化后就不能指向另一个对象了。而且一定要显示地初始化赋值。
- 所修饰的类,不能被继承,其中方法默认是final修饰
- final 修饰的方法不可被重写,但可以被重载
static 关键字
- 修饰代码块,使这个代码块在JVM加载之处就开辟一块空间单独存放代码块内容,且只加载一次。执行得到的结果存储在方法区并被线程共享。静态类中的方法直接和这个类关联,而不是和这个对象关联。可以直接通过类名来使用方法。
- 修饰非局部的成员变量,加载方式和静态代码块一样。由于在JVM内存中共享,会引起线程安全问题。解决:加final;使用同步(volatile 关键字)。
- 修饰方法,通过类名调用。静态方法不可以直接调用其他成员方法、成员变量。
抽象类和接口
接口只能用 public *和 *abstract *修饰
*区别分为四个方面:
- 成员变量:接口中默认public static final
- 成员方法:java8之前接口中默认是public,java8加入了static和default,java9中加入了private,方法不能用final修饰,因为需要实现类重写;抽象类无限制
- 构造器:接口和抽象类都不能被实例化,但接口中没有构造器,抽象类中有
- 继承:接口可以多继承,抽象类只能单继承
抽象类和接口的选择?
如果知道某个类应该成为基类,那么第一选择应该是让它成为一个接口,只有在必须要有方法定义和成员变量的时候,才应该选择抽象类。在接口和抽象类的选择上,必须遵守这样一个原则:行为模型应该总是通过接口而不是抽象类定义。通过抽象类建立行为模型会出现的问题:如果有一个产品类 A,有两个子类 B 和 C 分别有自己的功能,如果出现一个既有 B 产品功能又有 C 产品功能的新产品需求,由于 Java 不允许多继承就出现了问题,而如果是接口的话只需要同时实现两个接口即可。
异常
所有的异常都继承自Throwable类的,分为 Error 和 Exception。
- Error 类描述了 Java 运行时系统的内部错误和资源耗尽错误,如果出现了这种错误,一般无能为力。
- Error 和 RuntimeException 的异常属于非检查型异常,其他的都是检查型异常。
常见的 RuntimeException 异常:
- ClassCastException,错误的强制类型转换。
- ArrayIndexOutOfBoundsException,数组访问越界。
- NullPointerException,空指针异常。
常见的检查型异常:
FileNotFoundException,试图打开不存在的文件。
ClassNotFoundException,试图根据指定字符串查找 Class 对象,而这个类并不存在。
IOException,试图超越文件末尾继续读取数据。
异常处理:
- 抛出异常:遇到异常不进行具体处理,而是将异常抛出给调用者,由调用者根据情况处理。抛出异常有2种形式,一种是 throws 关键字声明抛出的异常,作用在方法上,一种是使用throw 语句直接抛出异常,作用在方法内。
- 捕获异常:使用 try/catch 进行异常的捕获,try 中发生的异常会被 catch 代码块捕获,根据情况进行处理,如果有 finally 代码块无论是否发生异常都会执行,一般用于释放资源,Java 7 开始可以将资源定义在 try 代码块中自动释放资源。
- try-catch-finally
- finally对try块中打开的物理资源进行回收(JVM垃圾回收机制回收对象占用的内存)。
- 这个回收如果放在catch中执行,不发生异常则不会被执行;放在try中,如发生异常前就被回收,那么catch就不会被执行。
- java7可以在try()圆括号中初始化或声明资源,会自动回收。但资源需要实现AutoCloseable接口
序列化
Java对象在JVM运行时被创建,JVM退出时存活对象被销毁。为了保证对象及其状态的持久化,就需要使用序列化了。序列化就是将对象通过ObjectOutputStream保存为字节流;反序列化就是将字节流还原为对象。
- 要实现Serializable接口来进行序列化。
- 序列化和反序列化必须保持序列化 ID 的一致。
- 静态、transient修饰的变量和方法不能被序列化。
- 实现Externalizable可以自行决定哪些属性可以被序列化
反射
在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取信息以及动态调用对象方法的功能就是Java的反射机制。优点是运行时动态获取类的全部信息,缺点是破坏了类的封装性,泛型的约束性。
- Class类保存对象运行时信息,可以通过①类名.class ②对象名.getClass()③Class.forName(类的全限定名)方式获取Class实例
- Class类中的getFields()返回这个类支持的公共字段;getMethods()返回公共方法;getCosntructors()返回构造器数组(包括父类公共成员)
- xxxDeclaredxxx()可以返回全部字段、方法和构造器的数组(不包括父类的成员)
注解
可以给类、接口或者方法、变量添加一些额外信息;帮助编译器和JVM完成一些特定功能。
- 元注解:我们可以自定义一个注解,这时就需要在自定义注解中使用元注解来标识一些信息
- @Target:约束注解作用位置:METHOD,VARIABLE,TYPE,PARAMETER,CONSTRUCTORS,LOACL_VARIABLE
- @Rentention:约束注解作用的生命周期:SOURCE 源码,CLASS 字节码,RUNTIME 运行时
- @Documented:表明这个注解应该被 javadoc 工具记录
- @Inherited:表面某个被标注的类型是被继承
泛型
泛型的提出是为了编写重用性更好的代码。泛型的本质是参数化类型,也就是说所操作的数据类型被指定为一个参数。
那么Java之所以引入它我认为主要有三个作用
- 类型检查,它将运行时类型转换的ClassCastException通过泛型提前到编译时期。
- 避免类型强转。
- 泛型可以泛型算法,增加代码的复用性。
实现
泛型是通过类型擦除来实现的,编译器在编译时擦除了所有类型相关的信息,所以在运行时不存在任何类型相关的信息。例如 List在运行时仅用一个List来表示。这样做的目的,是确保能和Java 5之前的版本开发二进制类库进行兼容。
Java的泛型是如何工作的 ? 什么是类型擦除 ?如何工作?
1、类型检查:在生成字节码之前提供类型检查
2、类型擦除:所有类型参数都用他们的限定类型替换,包括类、变量和方法(类型擦除)
3、如果类型擦除和多态性发生了冲突时,则在子类中生成桥方法解决
4、如果调用泛型方法的返回类型被擦除,则在调用该方法时插入强制类型转换
集合
数组和集合区别
- 区别1:
数组既可以存储基本数据类型,又可以存储引用数据类型, 基本数据类型存储的是值, 引用数据类型存储的是地址值;
集合只能存储引用数据类型(对象), 集合中也可以存储基本数据类型,但是在存储的时候会自动装箱(JDK1.5新特性)变成对象. - 区别2:
数组长度是固定的,不能自动增长;
集合的长度的是可变的, 可以根据元素的增加而增长. - 使用情况:
1. 如果元素个数是固定的, 推荐用数组
2. 如果元素个数不是固定的, 推荐用集合
List
List 是一种线性列表结构,元素是有序、可重复的。
ArrayList 和 LinkedList 对比:
ArrayList
是实现了基于动态数组的数据结构,LinkedList
是基于链表结构。- 对于随机访问的
get
和set
方法查询元素,ArrayList
要优于LinkedList
,因为LinkedList
循环链表寻找元素。 - 对于新增和删除操作
add
和remove
,LinkedList
比较高效,因为ArrayList
要移动数据。
优缺点:
- 对
ArrayList
和LinkedList
而言,在末尾增加一个元素所花的开销都是固定的。对ArrayList
而言,主要是在内部数组中增加一项,指向所添加的元素,偶尔可能会导致对数组重新进行分配;而对LinkedList
而言,这个开销是统一的,分配一个内部Entry
对象。 - 在
ArrayList
集合中添加或者删除一个元素时,当前的列表移动元素后面所有的元素都会被移动。而LinkedList
集合中添加或者删除一个元素的开销是固定的。 ArrayList
的空间浪费主要体现在在list列表的结尾预留一定的容量空间,而LinkedList
的空间花费则体现在它的每一个元素都需要消耗相当的空间- ArrayList和LinkedList都不是线程安全的。
应用场景:ArrayList
使用在查询比较多,但是插入和删除比较少的情况,而LinkedList
用在查询比较少而插入删除比较多的情况
ArrayList
- 底层由数组实现,随机访问(RandomAccess接口),读快写慢,由于写操作涉及元素的移动,因此写操作效率低。
- 三个成员变量:
- elementData 是 ArrayList 的数据域,会预留一些容量保证性能,transient修饰,不能被序列化;
- size表示list的实际大小,private;
- modCount继承自AbstractList,记录了ArrayList添加或者删除元素这种结构性变化的次数。protected transient修饰。
LinkedList
- 底层由链表实现,需要顺序访问元素,即使有索引也需要从头遍历,所以说写快读慢。
- LinkedList 实现了 Deque 接口,具有队列的属性,可在尾部增加元素,在头部获取元素,也能操作头尾之间任意元素。
- 成员变量,序列化原理类似ArrayList。
Vector 和 Stack
- Vector 的实现和 ArrayList 基本一致,底层使用的也是数组,区别主要在于:
(1)Vector 的所有公有方法都使用了 synchronized 修饰保证线程安全性。
(2)增长策略不同,Vector 多了一个成员变量 capacityIncrement 用于标明扩容的增量。 - Stack 是 Vector 的子类,实现和 Vector基本一致,与之相比多提供了一些方法表达栈的含义比如pop(),top()。
- Vector 的实现和 ArrayList 基本一致,底层使用的也是数组,区别主要在于:
HashSet
- HashSet 中的元素是无序、不重复的,最多只能有一个 null 值。
- HashSet 的底层是通过 HashMap 实现的,HashMap 的 key 值即 HashSet 存储的元素,所有 key 都使用相同的 value —— 一个static final 修饰的变量名为 PRESENT 的 Object 类型的对象。所有关于Set的操作都是直接调用HashMap的方法实现的。
- HashMap 是线程不安全的,因此 HashSet 也是线程不安全的。
- 去重:基本数据类型直接比较值;引用数据类型通过比较hashCode和equal方法
HashTable
- HashTable 继承自 Dictionary 类
- 底层是数组+链表,key和value都不能为null。因为添加数据put操作使用了 synchronized 同步锁,实现了线程安全。
- 初始容量是11,扩容方式是oldSize * 2 + 1
- 计算索引的方式:哈希值%table数组长度,取模运算计算消耗较大
相比而言,HashMap继承自AbstractMap类;JDK1.8开始底层是数组+链表/红黑树,key、value都可以为null,线程不安全;初始容量16,扩容oldSize * 2;计算数据储存索引的方式:哈希值和数组长度减一进行与运算。
TreeMap
TreeMap
是基于红黑树的一种提供顺序访问的Map,与HashMap不同的是它的get、put、remove之类操作都是o(log(n))的时间复杂度,具体顺序可以由指定的Comparator来决定,或者根据键的自然顺序来判断
HashMap
- HashMap继承自AbstractMap,实现了Map, Cloneable, Serializable接口。
- HashMap 默认初始化容量为 16,扩容是oldSize * 2;扩容容量必须是 2 的幂次方、最大容量为 2 的 30 次方 、默认加载因子为 0.75。
- 工作原理
HashMap在Map.Entry静态内部类实现中存储键值对。HashMap使用哈希算法,在put和get方法中,它使用hashCode()和equals()方法。
当我们通过传递键值对调用put方法的时候,HashMap使用Key hashCode()和哈希算法来找出存储键值对对的索引。Entry存储在链表中,所以如果存在entry,它使用equals()方法来检查传递的键是否已经存在,如果存在,它会覆盖原来的值,如果不存在,它会创建一个新的entry然后保存。当链表深度达到8的时候,会使用红黑树来存储数据。
当我们通过传递键调用get方法时,它再次使用hashCode()来找到数组中的索引,然后使用equals()方法找出正确的Entry,然后返回它的值。
JDK 8 之前
底层实现是数组 + 链表,主要成员变量包括:存储数据的 table 数组、键值对数量 size、加载因子 loadFactor。
table 数组用于记录 HashMap 的所有数据,它的每一个下标都对应一条链表,所有哈希冲突的数据都会被存放到同一条链表中,Entry 是链表的节点元素,包含四个成员变量:键 key、值 value、指向下一个节点的指针 next 和 元素的散列值 hash。
在 HashMap 中数据都是以键值对的形式存在的,键对应的 hash 值将会作为它在数组里的下标,如果两个元素 key 的 hash 值一样,就会发送哈希冲突,被放到同一个下标中的链表上,(为了使 HashMap 的查询效率尽可能高,应该使键的 hash 值尽可能分散。)put(K,V) 方法:添加元素 (重点掌握)
① 如果 key 为 null 值,直接存入 table[0]。
② 如果 key 不为 null 值,先计算 key 对应的散列值。
③ 调用 indexFor 方法根据 key 的散列值和数组的长度进行与计算决定元素存放的下标 i。
④ 遍历 table[i] 对应的链表,如果 key 已经存在,就更新 value 值然后返回旧的 value 值。
⑤ 如果 key 不存在,就将 modCount 的值加 1,使用 addEntry 方法增加一个节点,并返回 null 值。get 方法:根据 key 获取元素的 value 值
① 如果 key 为 null 值,调用 getForNullKey 方法,如果 size 为 0, 表示链表为空,返回 null 值。如果 size 不为 0,说明存在链表,遍历 table[0] 的链表,如果找到了 key 为 null 的节点则返回其 value 值,否则返回 null 值。
② key 不为 null,调用 getEntry 方法,如果 size 为 0 表示链表为空,返回 null 值。如果 size 不为 0,首先计算 key 的 hash 值,然后遍历该链表的所有节点,如果节点的 key 值和 hash 值都和要查找的元素相同则返回其 Entry 节点。
③ 如果找到了对应的 Entry 节点,使用 getValue 方法获取其 value 值并返回,否则返回 null 值。hash(Object key) 方法,计算哈希值:为了使哈希值更加分散,避免哈希冲突,采取的是将哈希值执行异或和无符号右移操作。
indexFor() 计算元素下标:哈希值和table数组长度-1进行与计算
resize 方法:根据newCapacity 来确定新的扩容阈值 threshold
① 如果当前容量已经达到了最大容量,就将阈值设置为 Integer 的最大值,之后扩容就不会再触发。
② 创建一个新的容量为 newCapacity 的 Entry 数组,并调用 transfer 方法将旧数组的元素转移到新数组。
③ 将阈值设为(newCapacity 和加载因子 loadFactor 的积)和(最大容量 + 1 )的较小值。transfer:转移旧数组到新数组
① 遍历旧数组的所有元素,调用 rehash 方法判断是否需要哈希重构,如果需要就重新计算元素 key 的散列值。
② 调用 indexFor 方法根据 key 的散列值和数组的长度计算元素存放的下标 i,利用头插法将旧数组的元素转移到新的数组。
JDK 8 开始
使用的是数组 + 链表/红黑树的形式,table 数组的元素数据类型换成了 Entry 的静态实现类 Node。
put 方法:添加元素 (重点掌握)
① 调用 putVal 方法添加元素。
② 如果 table 为空或没有元素时就进行扩容,否则计算元素下标位置,如果不存在就新创建一个节点存入。
table不为空的情况下插入值还分三种情况:
③ 如果首个节点和待插入元素的 hash 值和 key 值都一样,直接更新 value 值。
④ 如果首个节点是 TreeNode 类型,调用 putTreeVal 方法增加一个树节点,并对红黑树结构进行调整
⑤ 如果是链表节点,就遍历链表,根据 hash 值和 key 值判断是否重复,决定更新值还是新增节点。如果遍历到了链表末尾,添加链表元素,如果达到了建树阈值,还需要调用 treeifyBin 方法把链表重构为红黑树。
完成对元素的插入操作后:
⑥将 modCount 值加 1,如果节点数 + 1大于扩容阈值,还需要进行扩容。
⑦返回类型:如果更新了键值对就返回原来的值,否则返回null调整红黑树的具体实现:
每一次都比较插入节点和当前节点的大小,待插入节点小就往左子树查找,否则往右子树查找,找到空位后执行两个方法:balanceInsert 方法,把节点插入红黑树并对红黑树进行调整使之平衡。moveRootToFront 方法,由于调整平衡后根节点可能变化,table 里记录的节点不再是根节点,需要重置根节点。get 方法:根据 key 获取元素的 value 值
① 调用 getNode 方法获取 Node 节点,如果不是 null 值就返回 Node 节点的 value 值,否则返回 null。
② 如果数组不为空,先比较第一个节点和要查找元素的 hash 值和 key 值,如果都相同则直接返回。
③ 如果第一个节点是 TreeNode 节点则调用 getTreeNode 方法进行查找,否则遍历链表根据 hash 值和 key 值进行查找,如果没有找到就返回 null。hash方法 同java8以前
resize方法 由于没有transfer方法,所以java8中对hashMap重新规划长度有自己的具体实现。
重新规划长度
① 如果 size 超出扩容阈值,把 table 容量增加为之前的2倍。
② 如果新的 table 容量小于默认的初始化容量16,那么将 table 容量重置为16。
③ 如果新的 table 容量大于等于最大容量,那么将阈值设为 Integer 的最大值,并且 return 终止扩容,由于 size 不可能超过该值因此之后不会再发生扩容。
重新排列数据节点
① 如果节点为 null 值则不进行处理。
② 如果节点不为 null 值且没有next节点,那么重新计算其散列值然后存入新的 table 数组中。
③ 如果节点为 TreeNode 节点,那么调用 split 方法进行处理,该方法用于对红黑树调整,如果太小会退化回链表。
④ 如果节点是链表节点,需要将链表拆分为 hashCode() 返回值超出旧容量的链表和未超出容量的链表。对于hash & oldCap == 0
的部分不需要做处理,反之需要放到新的下标位置上,新下标 = 旧下标 + 旧容量。
线程不安全
- Java 8 以前 扩容时 resize 方法调用的 transfer 方法中使用头插法迁移元素,多线程会导致 Entry 链表形成环形数据结构,Entry 节点的 next 永远不为空,引起死循环。
- 在对table进行扩容到newTable后,需要将原来数据转移到newTable中,在转移元素的过程中,使用的是头插法,也就是链表的顺序会翻转,多线程时造成环形。
- Java 8 开始在 resize 方法中完成扩容,并且改用了尾插法,不会产生死循环的问题,但是在多线程的情况下put方法还是可能会导致数据覆盖的问题,因此依旧线程不安全。
- AB线程计算的插入下标相同,A线程检查哈希冲突后挂起,B线程完成插入操作,A线程随后继续操作,但不会检查哈希冲突,造成数据覆盖
HashMap在java1.8中相对于java1.7区别? ……
- 储存结构:(优化)
- 1.7 数组+单链表;Entry数组储存数据,Entry是链表结构。发生哈希冲突的元素会储存在相同下标的数组元素链表中,如果哈希冲突过多,会造成链表过长,使put/get效率低下 – O(n)复杂度
- 1.8 数组+单链表+红黑树;Node数组,可以是链表也可以是树。当链表深度大于8时,数据改又红黑树储存,这样put/get复杂度保证是O(logn)
- 插入数据
- 1.7 头插法 因为JDK1.7是用单链表进行的纵向延伸,当采用头插法时会容易出现逆序且环形链表死循环问题。
- 1.8 尾插法 在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。但多线程情况下可能发生数据覆盖的问题。
- 扩容方式不同
- 1.7 先扩容后插入 先新建数组,重新计算哈希值和索引位置
- 1.8 先插入后扩容 扩容前数据的原哈希值与要扩容后容量进行与操作,根据(新容量-1)新增的参与运算位是1还是0来判断是否需要扩容。 这个不是很理解
为什么在JDK1.7的时候是先进行扩容后进行插入,而在JDK1.8的时候则是先插入后进行扩容的呢?
待解答
在JDK1.8中进行对HashMap优化的时候,把链表转化为红黑树的阈值是8
- 这是设计源码的程序员根据泊松分布的概率得到的一个结果。容器中节点分布在hash桶中的频率遵循泊松分布,桶的长度超过8的概率非常非常小。
- 链表小于等于6树还原转为链表,大于等于8转为树,中间有个差值7可以有效防止链表和树频繁转换。
ConcurrentHashMap
1.7版本
- 相比HashMap,增加了Segment数组,每个Segement数组有对应的HashEntry数组。
- 区别:Segement类其中的核心数据如 value ,以及链表Entry<K,V>都是 volatile 修饰的,保证了获取时的可见性,使用get()方法能够随时获取最新的值;其他结构和 HashMap 类似。
- 采用分段锁技术,Segment 继承于 ReentrantLock,每当一个线程占用锁访问一个 Segment 时,不会影响到其他的 Segment。好处是能够保证线程安全,也不会像 HashTable 那样整张表加锁,以致于 put 和 get 操作都需要做同步处理,所以 ConcurrentHashMap 效率更高。
- 理论上 ConcurrentHashMap 支持 CurrencyLevel (Segment 数组数量)的线程并发。
put 方法
- HashEntry前的volatile关键字不能保证并发的原子性,所以执行put前需要加锁,Segment实现了ReentrantLock,通过Segment对象的put方法,别的线程无法对这个Segement中的hash进行操作,可以实现线程安全。
- 通过 key 计算得到的哈希值找到Segment,在Segment对象中进行具体的put操作。
- 插入数据前要获取锁。获取到锁之后,就寻找对应的HashEntry进行数据插入操作。
- 首先key值不能为空
- 如果传入的key值已存在,就进行覆盖原来的值
- 如果不存在,首先判断是否需要扩容,然后插入数据
- 数据插入完成,释放锁。
get方法
- 通过key值计算出的哈希值,找到对应的Segment
- 从Segment对应的HashEntry中找到值
- 过程无需获取锁,而且value是volatile修饰,保证了获取时一定是最新值。
1.8版本
结构上变化:
- 在容器安全上,1.8 中的 ConcurrentHashMap 放弃了 JDK1.7 中的分段技术,而是采用了 CAS 机制 + synchronized 来保证并发安全性,但是在 ConcurrentHashMap 实现里保留了 Segment 定义,这仅仅是为了保证序列化时的兼容性而已,并没有任何结构上的用处。
- 不再使用Segement,HashEntry改为了Node,作用和HashEntry类似,其中的value和next(链表)都使用volatile修饰。Node数组中元素个数大于8则改用红黑树存储数据。
- 添加了一个空参构造函数,好处就是实现了懒加载,减少了初始化开销。
- 初始化是在put操作中完成的。
- 调用initTable()执行初始化,使用CAS机制
- 如果 sizeCtl 值大于等于 0,则基于 CAS 策略抢占标记 sizeCtl 为 -1,表示 ConcurrentHashMap 正在执行初始化,然后构造 table,并更新 sizeCtl 的值
- CAS 通过将内存中的值与期望值进行比较,只有在两者相等时才会对内存中的值进行修改,CAS 不用加锁,但可以在保证性能的同时提供并发场景下的线程安全性。
put 方法(实际上通过putVal实现)
- 校验key是否为空,不为空计算出hashcode
- 判断是否要初始化
- 使用CAS策略或者syncronized锁添加数据到对应的链表或者红黑树中
- 根据计算得出的 hash 值找到对应的table数组下标位置,如果该位置未存放节点,也就是说不存在 hash 冲突,则使用 CAS 无锁的方式将数据添加到容器中,并且结束循环。
- 如果前面的条件没满足,则会判断容器是否正在被其他线程进行扩容操作,如果正在被其他线程扩容,则帮助扩容。(扩容中的数据迁移会将头结点使用同步锁锁住,防止别的线程putVal对该链表进行操作,保证了线程安全)
- 如果上面都不满足,说明发生了 hash 冲突,就进行链表操作或者红黑树操作),在进行链表或者红黑树操作时,会使用 synchronized 锁把头节点被锁住,保证了同时只有一个线程修改链表,防止出现链表成环。
- 最后更新链表大小,由此判断是否需要扩容。
get方法
- 根据计算出来的 hashcode 寻址,如果就在桶上那么直接返回值。
- 如果是红黑树那就按照树的方式获取值。
- 就不满足那就按照链表的方式遍历获取值。
transfer扩容方法(还未详看)
- 第一步:计算出每个线程每次可以处理的个数,根据 Map 的长度,计算出每个线程(CPU)需要处理的桶(table数组的个数),默认每个线程每次处理 16 个桶,如果小于 16 个,则强制变成 16 个桶。
- 第二步:对 nextTab 初始化,如果传入的新 table nextTab 为空,则对 nextTab 初始化,默认是原 table 的两倍
- 第三步:引入 ForwardingNode、advance、finishing 变量来辅助扩容,ForwardingNode 表示该节点已经处理过,不需要在处理,advance 表示该线程是否可以下移到下一个桶(true:表示可以下移),finishing 表示是否结束扩容(true:结束扩容,false:未结束扩容) ,具体的逻辑就不说了
- 第四步:跳过一些其他细节,直接到数据迁移这一块,在数据转移的过程中会加 synchronized 锁,锁住头节点,同步化操作,防止 putVal 的时候向链表插入数据
- 第五步:进行数据迁移,如果这个桶上的节点是链表或者红黑树,则会将节点数据分为低位和高位,计算的规则是通过该节点的 hash 值跟为扩容之前的 table 容器长度进行位运算(&),如果结果为 0 ,则将数据放在新表的低位(当前 table 中为 第 i 个位置,在新表中还是第 i 个位置),结果不为 0 ,则放在新表的高位(当前 table 中为第 i 个位置,在新表中的位置为 i + 当前 table 容器的长度)。
- 第六步:如果桶挂载的是红黑树,不仅需要分离出低位节点和高位节点,还需要判断低位和高位节点在新表以链表还是红黑树的形式存放。
Java8 新特性
lambda 表达式:lambda 表达式允许把函数作为一个方法的参数传递到方法中,主要用来简化匿名内部类的代码。
函数式接口:使用
@FunctionalInterface
注解标识,有且仅有一个抽象方法,可以被隐式转换为 lambda 表达式。方法引用:可以直接引用已有类或对象的方法或构造器,进一步简化 lambda 表达式。方法引用有四种形式:引用构造方法、引用类的静态方法、引用特定类的任意对象方法、引用某个对象的方法。
接口中的方法:接口中可以定义
default
修饰的默认方法,降低了接口升级的复杂性,还可以定义静态方法。注解:Java 8 引入了重复注解机制,相同的注解在同一个地方可以声明多次。注解的作用范围也进行了扩展,可以作用于局部变量、泛型、方法异常等。
类型推测:加强了类型推测机制,可以使代码更加简洁,例如在定义泛型集合时可以省略对象中的泛型参数。
Optional 类:用来处理空指针异常,提高代码可读性。
Stream 类:把函数式编程风格引入 Java 语言,提供了很多功能,可以使代码更加简洁。方法包括
forEach()
遍历、count()
统计个数、filter()
按条件过滤、limit()
取前 n 个元素、skip()
跳过前 n 个元素、map()
映射加工、concat()
合并stream流等。日期:增强了日期和时间的 API,新的 java.time 主要包含了处理日期、时间、日期/时间、时区、时刻和时钟等操作。
JVM
内存区域划分
程序计数器
是一块较小的内存空间,可以看作当前线程执行字节码的指示器,是唯一没有内存溢出的区域。字节码解释器工作时通过改变计数器的值选取下一条执行指令。分支、循环、跳转、线程恢复等功能都需要依赖计数器。
如果线程正在执行 Java 方法,计数器记录正在执行的虚拟机字节码指令地址。如果是本地方法,计数器值为 Undefined。Java虚拟机栈
是线程私有的,描述 Java 方法的内存模型。当有新线程创建时会分配一个栈空间,栈中元素用于支持虚拟机进行方法调用,每个方法在执行时都会创建一个栈帧存储方法的局部变量表、操作栈和方法出口等信息。每个方法从调用到执行完成,就是栈帧从入栈到出栈的过程。
线程请求的栈深度大于虚拟机允许的深度抛出 StackOverflowError;如果 JVM 栈允许动态扩展,栈扩展无法申请足够内存抛出 OutOfMemoryError。本地方法栈
作用、抛出异常和虚拟机栈类似,但是本地方法栈为本地方法服务。(本地方法是非java代码实现的方法)Java堆
Java 堆是虚拟机所管理的内存中最大的一块。堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。它目的是为了存放对象实例,Java 里几乎所有的对象实例都在这里分配内存。
Java 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。但对于大对象(例如数组),多数虚拟机实现出于简单、存储高效的考虑会要求连续的内存空间。
如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,虚拟机将抛出 OutOfMemoryError 异常。方法区
存储被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等等数据。
虚拟机规范对方法区的约束宽松,除和堆一样不需要连续内存、可选择固定大小、可扩展外,还可以不实现垃圾回收。垃圾回收在方法区出现较少,主要针对常量池和类型卸载。
如果方法区无法满足新的内存分配需求时,将抛出 OutOfMemoryError 异常。运行时常量池
是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译器生成的各种字面量与符号引用,这部分内容在类加载后存放到运行时常量池。
运行时常量池相对于 Class 文件常量池的一个重要特征是动态性,Java 不要求常量只有编译期才能产生。
例如 String 的intern
方法。intern
方法是一个本地方法,作用是如果字符串常量池中已包含一个等于此 String 对象的字符串,则返回池中这个字符串的 String 对象的引用,否则将此 String 对象包含的字符串添加到常量池并返回此 String 对象的引用。内存溢出和内存泄漏
- 内存溢出 OutOfMemory,指程序在申请内存时,没有足够的内存空间供其使用。
- 内存泄露 Memory Leak,指程序在申请内存后,无法释放已申请的内存空间,内存泄漏最终将导致内存溢出。
对象创建的过程
① 当 JVM 遇到字节码 new 指令时,首先检查能否在常量池中定位到一个类的符号引用,并检查该类是否已被加载。
② 在类加载检查通过后虚拟机将为新生对象分配内存。
③ 内存分配完成后虚拟机将成员变量设为零值,保证对象的实例字段可以不赋初值就使用。
④ 设置对象头,包括哈希码、GC 信息、锁信息、对象所属类的类元信息等。
⑤ 执行 init 方法,初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。
垃圾回收机制
- 需要垃圾回收的原因:
- Java运行时的数据,比如对象,数组都会储存在Java堆中。但如果动态创建的对象没有得到及时回收造成持续堆积,就会使堆被占满,从而内存溢出。
- 为了解决这个问题,Java在后台创建一个守护进程,在内存紧张时回收堆中无用或者长时间没使用的的对象,保证程序正常运行。
- 垃圾回收只会负责释放那些对象占有的内存。
判断对象是否是垃圾
在堆中存放着所有对象实例,垃圾收集器在对堆进行回收前,首先要判断对象是否还存活着。
引用计数算法
在对象中添加一个引用计数器,如果有一个地方引用它计数器就加1,引用失效时计数器就减1,如果计数器为0则该对象就是不再被使用的。该算法原理简单,效率也高,但是在 Java中很少使用,因为它存在对象之间互相循环引用的问题,导致计数器无法清零。
可达性分析算法
基本思路是把所有引用的对象想象成一棵树,从树的根结点 GC Roots 出发,根据引用关系向下搜索,持续遍历找出所有连接的树枝对象,这些对象则被称为“可达”对象,或称“存活”对象。其余的对象则被视为“死亡”的“不可达”对象,或称“垃圾”。
GC Roots 一定可达。可作为GC Roots的对象:
- 在虚拟机栈中引用的对象,如线程被调用的方法堆栈中的参数、局部变量等。
- 在方法区中类静态属性引用的对象,如类的引用类型静态变量。
- 在方法区中常量引用的对象,如字符串常量池中的引用。
- 在本地方法栈中 JNI 即 Native 方法引用的对象。
- JVM 内部的引用,如基本数据类型对应的 Class 对象,一些常驻异常对象,系统类加载器等。
- 所有被 synchronized 同步锁持有的对象。
引用类型
无论通过引用计数还是可达性分析判断对象是否存活,都和引用离不开关系。在 JDK1.2 之前引用的定义是:如果 reference 类型数据存储的数值代表另外一块内存的起始地址,那么就称该 reference 数据是代表某块内存、某个对象的引用。在 JDK 1.2之后 Java 对引用的概念进行了扩充,按强度分为四种:
强引用:最传统的引用定义,指代码中普遍存在的引用赋值。任何情况下只要强引用存在,垃圾收集器就永远不会回收被引用的对象。
软引用:描述一些还有用但非必需的对象。只被软引用关联的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围中进行二次回收,如果这次回收还没有足够的内存才会抛出 OOM 异常。
弱引用:描述非必需对象,引用强度比软引用更弱,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器开始工作时无论当前内存是否足够都会回收只被弱引用关联的对象。
虚引用:是最弱的引用关系。一个对象是否有虚引用存在,完全不会对其生存时间造成影响,也无法通过虚引用来取得一个对象实例。该引用的唯一目的就是为了能在这个对象被垃圾收集器回收时收到一个系统通知
Java 堆空间结构
分为三部分:
- 新生代:新创建的对象。很多局部变量等在新创建后很快会变成不可达的对象,快速死去,因此这块区域的特点是存活对象少,垃圾多。
- 老年代:对象很早被创建,并一直存活下来。这一块区域的对象特点是存活对象多,垃圾少。
- 永久代:永久存在的对象。比如一些静态文件。特点是不需要被回收。
GC 算法
标记清除
- 分为标记和清除两个阶段,首先利用可达性遍历堆内存,标记存活或者需要回收的对象;第二步就是把标记为垃圾的对象内存清空。
- 标记过程就是判断对象是否属于垃圾的过程。如果堆包含大量对象且大部分需要回收,必须进行大量标记清除,效率低。
- 缺点:存在内存空间碎片化问题,分配大对象时容易触发 Full GC。
标记复制(新生代)
基本算法
- 将可用内存按容量划分为(大小相等的)两块,每次只使用其中一块。当这一块的空间用完了,就将还存活着的对象复制到另一块,然后再把当前的内存空间直接清空。
- 适用于垃圾多,存活对象少的情况,这样就可以减少移动次数。
- 缺点:对象存活率高时要进行较多复制操作,效率低。如果不想浪费空间就需要有额外空间分配担保,老年代一般不使用此算法。
实际我们将内存分为8比1比1
的大小,分别为Eden区和两个Survivor区,垃圾回收时使用Eden和其中一个Surivior区。
算法步骤:
- 首先,Eden区最大,对外提供堆内存。当 Eden 区快要满了,则进行 Minor GC,把存活对象放入Survivor A区,清空 Eden 区;
Eden 区被清空后,继续对外提供堆内存 - 当Eden区再次被填满,此时对Eden区和Survivor A区同时进行 Minor GC,把存活对象放入Survivor B区,同时清空Eden 区和Survivor A区
- Eden区继续对外提供堆内存,并重复上述过程,即在Eden区填满后,把Eden区和某个Survivor区的存活对象放到另一个Survivor区
- 当某个Survivor区被填满,且仍有对象未被复制完毕时,或者某些对象在反复Survive 15 次左右时,则把这部分剩余对象放到Old区;
当 Old 区也被填满时,进行 Major GC,对 Old 区进行垃圾回收。
标记整理(老年代)
- 主要是为了解决标记清理的碎片化问题。标记过程与标记清除算法一样,但不直接清理可回收对象,而是让所有存活对象都向内存空间一端移动,然后清理掉边界以外的少量的垃圾内存。
- 适合存活对象多,垃圾少的情况
- 标记清除与标记整理的区别:前者是一种非移动式算法,后者是移动式的。如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活的区域,开销很大,而且移动必须暂停用户线程;如果不移动会导致空间碎片问题。
Minor GC, Major GC, Full GC
- 新生代的内存回收是 Minor GC
- 可能的问题:老年代可能引用新生代对象,这样扫描时就需要扫描老年对象,相当于进行了全堆扫描。
- 解决:卡表技术。维护一个卡表,脏卡就是指该老年代对象可能引用了新生代对象。对脏卡进行扫描即可。(时间换空间)
- Major GC 回收老年代垃圾,在垃圾回收算法中一般由 Minor GC 之后:当堆中某一个 Survivor 区被占满后,会将这部分内存放到Old区;Old区填满后进行 Major GC
- Full GC:收集整个堆,包括 新生代,老年代,永久代(在 JDK 1.8及以后,永久代被移除,换为metaspace 元空间)等所有部分的模式
垃圾收集器
新生代
Serial
- 是一个使用复制算法的单线程工作的新生代收集器,它进行垃圾收集时必须暂停其他所有工作线程直到收集结束。(Stop the world)
- Serial 是虚拟机运行在客户端模式下的默认新生代收集器,优点是简单高效,对于内存受限的环境它是所有收集器中最小的;对于单核处理器或处理器核心较少的环境来说,Serial 收集器由于没有线程交互开销,因此可获得最高的单线程收集效率。
ParNew
- 是 Serial 的多线程版本,除了使用多线程进行垃圾收集外其余行为完全一致(所有控制参数、收集算法(复制算法)、Stop The World、对象分配规则、回收策略等)。
- ParNew 是虚拟机运行在服务端模式下的默认新生代收集器,一个重要原因是除了 Serial 外只有它能与 CMS 配合。自从 JDK 9 开始,ParNew 加 CMS 收集器的组合就不再是官方推荐的服务端模式下的收集器解决方案了,官方希望他能被 G1 完全取代。
Parallel Scavenge
- 与 ParNew 类似,新生代收集器,基于标记-复制算法,是可以并行的多线程收集器。
- 特点是它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能缩短收集时用户线程的停顿时间,而 Parallel Scavenge 的目标是达到一个可控制的吞吐量,吞吐量就是处理器用于运行用户代码的时间与处理器消耗总时间的比值。
- 高吞吐量可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
- 自适应调节策略也是它区别于 ParNew 的一个重要特性。可以根据当前系统的运行情况收集性能监控信息,动态调整垃圾收集参数(Survivor-Eden区比例,晋升老年代年龄等等),保证最大吞吐量。
老年代
- Serial Old: Serial收集器的老年代版本,使用“标记-整理”算法。
- Parallel Old:Parallel Scavenge 收集器的老年代版本,使用多线程和“标记-整理”算法,吞吐量优先。
CMS(Concurrent Mark Sweep)
- 以获取最短回收停顿时间为目标的收集器,如果希望系统停顿时间尽可能短以给用户带来更好的体验就可以使用 CMS。
- 基于标记-清除算法,分为四个步骤:初始标记、并发标记、重新标记、并发清除。
- 其中初始标记和重新标记仍然需要 STW(Stop The World,表示系统停顿),初始标记仅是标记 GC Roots 能直接关联到的对象,速度很快。
- 并发标记就是从 GC Roots 的直接关联对象开始遍历整个对象图的过程,所有步骤中耗时最长但不需要停顿用户线程,可以与垃圾收集线程并发运行。
- 重新标记则是为了修正并发标记期间因用户程序运作而导致标记产生变动的那一部分对象的标记记录,该阶段停顿时间比初始标记稍长,但远比并发标记短。
- 清理标记阶段判断的已死亡对象,由于不需要移动存活对象,因此该阶段也可以与用户线程并发。
- 由于整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器都可以和用户线程一起工作,所以从总体上说CMS 的内存回收过程是与用户线程并发执行的。
G1
- 是一款主要面向服务端的收集器,最初设计目标是替换CMS,相应速度优先。
- G1 可以面向堆内存任何部分来组成回收集进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收受益最大,这就是 G1 的 MixedGC 模式。
- 把 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要扮演新生代的 Eden 空间、Survivor 空间或老年代空间。收集器能够对扮演不同角色的 Region 采用不同的策略处理,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
- 跟踪各个 Region 里面的垃圾堆积的价值大小,也就是回收所获得的空间大小以及回收所需时间的经验值,在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间优先处理回收价值收益最大的 Region。这种回收方式保证了 G1 在有限的时间内获取尽可能高的收集效率。
- 为了避免全堆扫描的发生,虚拟机为G1中每个Region维护了一个与之对应的Remembered Set。虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过卡表把相关引用信息记录到被引用对象所属的 Region 的 Remembered Set 之中。当进行内存回收时,在GC根节点的枚举范围中加入 Remembered Set 即可保证不对全堆扫描也不会有遗漏。
- G1从整体来看是基于“标记-整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的。这意味着G1运行期间不会产生内存空间碎片,收集后能提供规整的可用内存。此特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
G1的运作过程:
- 初始标记:标记 GC Roots 能直接关联到的对象并修改 TAMS (Nest Top Mark Start)指针的值,让下一阶段用户线程并发运行时能正确地在可用 Region 中分配新对象。该阶段需要 STW 但耗时很短,是借用进行 MinorGC 时同步完成的。
- 并发标记:从 GC Roots 开始对堆中对象进行可达性分析,递归扫描整个堆的对象图,找出需要回收的对象。该阶段耗时长,但可与用户线程并发执行,当对扫描完成后还要重新处理 SATB 记录的在并发时有引用变动的对象。
- 最终标记:对用户线程做一个短暂暂停,用于处理并发阶段结束后仍遗留下来的少量 SATB 记录。
- 筛选回收:对各个 Region 的回收价值和成本排序,根据用户期望的停顿时间指定回收计划,可自由选择任意多个 Region 构成回收集然后把决定回收的那一部分的存活对象复制到空的 Region 中,再清理掉整个旧的 Region 的全部空间。该操作必须暂停用户线程,由多条收集器线程并行完成。
可以由用户指定期望的停顿时间是 G1 的一个强大功能,但该值不能设得太低,一般设置为100~300毫秒比较合适。G1不会存在内存空间碎片的问题,但 G1 为了垃圾收集产生的内存占用和程序运行时的额外执行负载都高于CMS。
CMS 是 HotSpot 追求低停顿的第一次成功尝试,但还存在三个明显缺点:① 对处理器资源非常敏感,在并发阶段虽然不会导致用户线程暂停,但会降低总吞吐量。② 无法处理浮动垃圾,有可能出现并发失败而导致另一次 FullGC。③ 由于基于标记-清除算法,因此会产生大量空间碎片,给大对象分配带来麻烦。
GC的优化?
类加载机制
Java 程序运行过程
- 首先通过 Javac 编译器将
.java
文件转为 JVM 可加载的.class
字节码文件。编译过程分为:
① 词法解析,通过空格分割出单词、操作符、控制符等信息,形成 token 信息流,传递给语法解析器。
② 语法解析,把 token 信息流按照 Java 语法规则组装成语法树。
③ 语义分析,检查关键字使用是否合理、类型是否匹配、作用域是否正确等。
④ 字节码生成,将前面各个步骤的信息转换为字节码。 - 之后通过即时编译器 JIT 把字节码文件编译成本地机器码。
- Java 程序最初都是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会认定其为热点代码,热点代码的检测主要有采样和计数器两种方式,为了提高热点代码的执行效率,虚拟机会把它们编译成本地机器码。
类加载
- Class 文件中的信息需要加载到虚拟机后才能使用。JVM 把描述类的数据从 Class 文件加载到内存,并对数据进行校验、解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程称为虚拟机的类加载机制。
- Java 类的加载、连接和初始化都是在运行期间完成的,这增加了性能开销,但提供高扩展性,Java 动态扩展的特性就是依赖运行期动态加载和连接实现的。
- 类型从被加载到卸出,整个生命周期经历加载、验证、准备、解析、初始化、使用和卸载七个阶段,其中验证、解析和初始化三部分称为连接。加载、验证、准备、初始化的顺序是确定的,解析则不一定:可能在初始化后再开始,这是为了支持 Java 的动态绑定。
类初始化的情况
① 遇到 new
、getstatic
、putstatic
字节码指令时,还未初始化。例如 new 实例化对象、设置静态字段、调用静态方法。
② 对类反射调用时,还未初始化。
③ 初始化类时,父类还未初始化。(接口初始化时不要求父接口初始化,只有在真正使用父接口时才会初始化,如引用接口常量)
④ 虚拟机启动时,会先初始化包含 main 方法的主类。
⑤ 接口定义了默认方法,如果接口的实现类初始化,接口要在其之前初始化。
其余所有引用类型的方式都不会触发初始化,称为被动引用。被动引用举例:
① 子类使用父类的静态字段时,只有父类被初始化。
② 通过数组定义使用类。
③ 常量在编译期会存入调用类的常量池,不会初始化定义常量的类。
类加载的过程⭐
加载
通过一个类的全限定类名获取对应的二进制字节流,将这个流所代表的静态存储结构转化为方法区的运行时数据区,然后在内存中生成对应该类的 Class 实例,作为方法区中这个类的数据访问入口。
验证
确保 Class 文件的字节流符合约束。
- 如果虚拟机不检查输入的字节流,可能因为载入有错误或恶意企图的字节流而导致系统受攻击。验证主要包含:文件格式验证、元数据验证、字节码验证、符号引用验证。
验证通过后对程序运行期没有任何影响。如果代码已被反复使用和验证过,在生产环境就可以考虑关闭大部分验证缩短类加载时间。
准备
- 为类静态变量分配内存并设置零值。
该阶段进行的内存分配仅包括类变量,不包括实例变量。如果变量被 final 修饰,编译时 Javac 会为变量生成 ConstantValue 属性,准备阶段虚拟机会将变量值设为代码值。
解析
将常量池内的符号引用替换为直接引用。
- 符号引用以一组符号描述引用目标,可以是任何形式的字面量,只要使用时能无歧义地定位目标即可,引用目标不一定已经加载到虚拟机内存;直接引用是可以直接指向目标的指针、相对偏移量或能间接定位到目标的句柄,引用目标必须已在虚拟机的内存中存在。
初始化
- 直到该阶段 JVM 才开始执行类中编写的代码。
- 准备阶段时变量赋过零值,初始化阶段会根据程序员的编码去初始化类变量和其他资源。初始化阶段就是执行类构造方法中的
<client>
方法,该方法是 Javac 自动生成的。
类加载器
启动类加载器(BootStrap)
在 JVM 启动时创建,负责加载最核心的类,例如 Object、System 等。无法被程序直接引用,如果需要把加载委派给启动类加载器,直接使用 null 代替即可,因为启动类加载器通常由操作系统实现,并不存在于 JVM 体系。
平台类加载器
从 JDK9 开始从扩展类加载器更换为平台类加载器,负载加载一些扩展的系统类,比如 XML、加密、压缩相关的功能类等。
应用类加载器
负责加载用户类路径上的类库,可以直接在代码中使用。如果没有自定义类加载器,一般情况下应用类加载器就是默认的类加载器。自定义类加载器通过继承 ClassLoader 并重写 findClass
方法实现。
双亲委派模型
- 双亲委派模型要求除了顶层的启动类加载器外,其余类加载器都应该有自己的父加载器。
- 某个特定的类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。
- 类跟随它的加载器一起具备了有优先级的层次关系,确保某个类在各个类加载器环境中都是同一个,通过这种层级关可以避免类的重复加载,保证程序的稳定性。
具体含义
Java虚拟机的第一个类加载器是Bootstrap,这个加载器很特殊,它不是Java类,因此它不需要被别人加载,它嵌套在Java虚拟机内核里面,也就是JVM启动的时候Bootstrap就已经启动,它是用C++写的二进制代码(不是字节码),它可以去加载别的类。
- 这也是我们在测试时为什么发现
System.class.getClassLoader()
结果为null的原因,这并不表示System这个类没有类加载器,而是它的加载器比较特殊,是BootstrapClassLoader
,由于它不是Java类,因此获得它的引用肯定返回null。
- 这也是我们在测试时为什么发现
委托机制具体含义
当Java虚拟机要加载一个类时,到底派出哪个类加载器去加载呢?- 首先当前线程的类加载器去加载线程中的第一个类(假设为类A)。
注:当前线程的类加载器可以通过Thread类的getContextClassLoader()获得,也可以通过setContextClassLoader()自己设置类加载器。 - 如果类A中引用了类B,Java虚拟机将使用加载类A的类加载器去加载类B。
- 还可以直接调用
ClassLoader.loadClass()
方法来指定某个类加载器去加载某个类。
- 首先当前线程的类加载器去加载线程中的第一个类(假设为类A)。
委托机制的意义: 防止内存中出现多份同样的字节码
比如两个类A和类B都要加载System类:- 如果不用委托而是自己加载自己的,那么类A就会加载一份System字节码,然后类B又会加载一份System字节码,这样内存中就出现了两份System字节码。
- 如果使用委托机制,会递归的向父类查找,也就是首选用Bootstrap尝试加载,如果找不到再向下。这里的System就能在Bootstrap中找到然后加载,如果此时类B也要加载System,也从Bootstrap开始,此时Bootstrap发现已经加载过了System那么直接返回内存中的System即可而不需要重新加载,这样内存中就只有一份System的字节码了。
判断两个类是否相等
任意一个类都必须由类加载器和这个类本身共同确立其在虚拟机中的唯一性。两个类只有由同一类加载器加载才有比较意义,否则即使两个类来源于同一个 Class 文件,被同一个 JVM 加载,只要类加载器不同,这两个类就必定不相等。
并发
多线程
生命周期(状态)
- New:新建状态,线程创建后进入的状态
- Runnable:就绪状态,就是可被执行。调用了线程的start()方法时,线程进入就绪状态。
- Running:运行中状态,获得CPU后,线程调度程序从可运行池中选择一个线程作为当前线程时线程所处的状态。这也是线程进入运行状态的唯一一种方式。
- Blocked:阻塞状态,失去CPU时间片后。可能由于锁被其他线程占用(获取同步锁失败)、调用了 sleep 或 join 方法、执行了 wait方法等。
- Waiting: 等待状态,处在这个状态的线程不会被分配 CPU 时间片,需要其他线程通知或中断。可能由于调用了无参的 wait 和 join 方法。
- Time-Waiting:限期等待状态,可以在指定时间内自行返回。导可能由于调用了带参的 wait 和 join 方法。
- Terminated:终止状态,表示当前线程已执行完毕或异常退出。
线程创建方式
继承Thread类创建线程类
(1)定义Thread类的子类,并重写该类的run方法,这个run方法的方法体就代表了线程要完成的任务.
(2)创建Thread子类的实例,也就是创建了线程对象。
(3)调用线程对象的start()方法来启动该线程。
实现简单,但不符合里氏替换原则,不可以继承其他类。通过Runnable接口创建线程类
(1)定义Runnable接口的实现类,并重写该接口的run方法,该run方法的方法体是该线程的线程执行体。
(2)创建 Runnable实现类的实例,并根据这个实例作为Thread的target来创建Thread对象,这个Thread对象才是真正的线程对象。
(3)调用线程对象的start()方法来启动该线程。
避免了单继承局限性,实现解耦。通过Callable和Future创建线程
(1)创建Callable接口的实现类,并重写call方法,call方法将作为线程执行体,并且有返回值。
(2)创建Callable实现类的实例,使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。
(3)使用FutureTask对象作为Thread对象的target创建并启动新线程。
(4)调用FutureTask对象的get()方法来获得子线程执行结束后的返回值
可以获取线程执行结果的返回值,并且可以抛出异常。还可以使用线程池创建
线程方法
- wait():让当前线程处于Waiting状态,直到其他线程调用此对象的 notify() 方法或 notifyAll() 方法,当前线程进入就绪状态。
- notify() 和 notifyAll():唤醒单个或所有线程
- sleep():进入休眠状态,和wait不同的是不会立即释放锁资源,进入Time-Waiting状态
- yield():使当前线程让出 CPU 时间片给优先级相同或更高的线程,回到就绪状态,与其他线程重新竞争 CPU 时间片。
- join(): 用于等待其他线程运行终止。如果当前线程调用了另一个线程的 join 方法,则当前线程进入阻塞状态,当另一个线程结束时当前线程才能从阻塞状态转为就绪状态,等待获取CPU时间片。底层使用 wait,也会释放锁。
start 和 run 的区别
- start(): 它的作用是启动一个新线程,新线程会执行相应的run()方法。start()不能被重复调用。
- run(): run()就和普通的成员方法一样,可以被重复调用。单独调用run()的话,会在当前线程中执行run(),而并不会启动新线程!
Sleep()和Wait()
每个对象都有一个锁来控制同步访问,Synchronized关键字可以和对象的锁交互,来实现同步方法或同步块。
- sleep()方法正在执行的线程主动让出CPU(然后CPU就可以去执行其他任务),在sleep指定时间后CPU再回到该线程继续往下执行(注意:sleep方法只让出了CPU,而并不会释放同步资源锁!!!);wait()方法则是指当前线程让自己暂时退让出同步资源锁,以便其他正在等待该资源的线程得到该资源进而运行,只有调用了notify()方法,之前调用wait()的线程才会解除wait状态,可以去参与竞争同步资源锁,进而得到执行。(注意:notify的作用相当于叫醒睡着的人,而并不会给他分配任务,就是说notify只是让之前调用wait的线程有权利重新参与线程的调度)
- sleep()方法可以在任何地方使用;wait()方法则只能在同步方法或同步块中使用;
- sleep()是线程类(Thread)的方法,调用会暂停此线程指定的时间,但监控依然保持,不会释放对象锁,到时间自动恢复;wait()是Object的方法,调用会放弃对象锁,进入等待队列,待调用notify()/notifyAll()唤醒指定的线程或者所有线程,才会进入锁池,不再次获得对象锁才会进入运行状态;
notify 和 notifyAll
- 如果线程调用了对象的 wait()方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。
- 当有线程调用了对象的 notifyAll()方法(唤醒所有 wait 线程)或 notify()方法(只随机唤醒一个 wait 线程),被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。也就是说,调用了notify后只要一个线程会由等待池进入锁池,而notifyAll会将该对象等待池内的所有线程移动到锁池中,等待锁竞争
- 优先级高的线程竞争到对象锁的概率大,假若某线程没有竞争到该对象锁,它还会留在锁池中,唯有线程再次调用 wait()方法,它才会重新回到等待池中。而竞争到对象锁的线程则继续往下执行,直到执行完了 synchronized 代码块,它会释放掉该对象锁,这时锁池中的线程会继续竞争该对象锁。
- notifyAll调用后,会将全部线程由等待池移到锁池,然后参与锁的竞争,竞争成功则继续执行,如果不成功则留在锁池等待锁被释放后再次参与竞争。而notify只会唤醒一个线程。
用户线程和守护线程
isDaemon()设置为false是用户线程,就是创建的普通线程;设为true就是守护线程,用来服务用户线程。
当线程只剩下守护线程的时候,JVM就会退出。垃圾回收线程就是守护线程。
注意:
- 守护线程要在线程start方法前声明
- 守护线程不能访问固有资源,如读写操作
- 线程池不能使用守护线程?
原子性、可见性、有序性
- 原子性: Java中,对基本数据类型的读取和赋值操作是原子性操作,所谓原子性操作就是指这些操作是不可中断或被分割的,要做一定做完,要么就没有执行。
- 可见性:Java就是利用volatile来提供可见性的。 当一个变量被volatile修饰时,那么对它的修改会立刻刷新到主存,当其它线程需要读取该变量时,会去内存中读取新值。而普通变量则不能保证这一点。
- 有序性:JMM是允许编译器和处理器对指令重排序的,但是规定了as-if-serial语义,即不管怎么重排序,程序的执行结果不能改变。
线程间通信机制
以什么样的机制来交换信息?有两种,共享内存和消息传递。
- 共享内存:
线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。
Java 并发采用共享内存模型,线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。 - 消息传递:
线程之间没有公共状态,线程之间必须通过发送消息来显示通信。 - volatile 告知程序任何对变量的读需要从主内存中获取,写必须同步刷新回主内存,保证所有线程对变量访问的可见性。
- 锁机制 synchronized 确保多个线程在同一时刻只能有一个处于方法或同步块中,保证线程对变量访问的原子性、可见性和有序性。
- 等待通知机制 通过join,wait,notify方法实现。
具体:指一个线程 A 调用了对象的wait
方法进入等待状态,另一线程 B 调用了对象的notify/notifyAll
方法,线程 A 收到通知后结束阻塞并执行后序操作。对象上的wait
和notify/notifyAll
完成等待方和通知方的交互。
如果一个线程执行了某个线程的join
方法,这个线程就会阻塞等待执行了join
方法的线程终止,这里涉及等待/通知机制。join
底层通过wait
实现,线程终止时会调用自身的notifyAll
方法,通知所有等待在该线程对象上的线程。 - 管道 IO 流 用于线程间数据传输,媒介为内存。
PipedOutputStream 和 PipedWriter 是输出流,相当于生产者,PipedInputStream 和 PipedReader 是输入流,相当于消费者。管道流使用一个默认大小为 1KB 的循环缓冲数组。输入流从缓冲数组读数据,输出流往缓冲数组中写数据。当数组已满时,输出流所在线程阻塞;当数组首次为空时,输入流所在线程阻塞。 - ThreadLocal 是线程共享变量,但它可以为每个线程创建单独的副本,副本值是线程私有的,互相之间不影响。
线程间同步机制(线程冲突解决)
互斥量(mutex)
互斥量本质上是一把锁,在访问共享资源前对互斥量进行加锁,在访问完成后释放互斥量上的锁。
对互斥量进行加锁以后,任何其它试图再次对互斥量加锁的线程将会被阻塞直到当前线程释放该互斥锁。
读写锁
读写锁与互斥量类似,不过读写锁允许更高的并行性。互斥量要么是锁住状态要么是不加锁状态,而且一次只有一个线程可以对其加锁。
而读写锁可以有三种状态:读模式下加锁状态、写模式下加锁状态、不加锁状态。一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。
volatile关键字
JVM提供的最轻量级的同步机制。被volatile修饰的变量有两种特性:
保证此变量对所有线程的可见性
可见性是指当一条线程修改了这个变量的值,新值对于其他线程来说是立即可以得知的。禁止指令重排序优化
使用 volatile 变量进行写操作,生成的汇编指令操作是带有 lock 前缀的,相当于一个内存屏障,后面的指令不能重排到内存屏障之前的位置。
使用 lock 前缀的指令在多核处理器有两个功能:
① 将当前处理器缓存行的数据写回到系统内存。
② 这个写回内存的操作会使其他在CPU里缓存了该内存地址的数据无效。这种操作相当于对缓存中的变量做了一次 store 和 write 操作,可以让 volatile 变量的修改对其他处理器立即可见。使用:状态量标记,对变量的读写操作,标记状态量保证修改对线程立即可见,效率比同步锁好。单例模式的实现,典型的双重检查锁定(DCL)。
重排序
就是编译器和处理器将指令的执行顺序进行先后调整。但必须符合有序性,即无论指令怎么排序,程序执行结果不能变。多线程程序重排序会造成结果不一致,所以使用volatile关键字禁止重排序,确保“有序性”。
syncronized 关键字
synchronized
采用的是 CPU 悲观锁机制,即线程获得的是独占锁, 其他线程只能依靠阻塞来等待线程释放锁。- 不同线程对同步锁的访问是互斥的。也就是说,某时间点,对象的同步锁只能被一个线程获取到。通过同步锁,我们就能在多线程中,实现对“对象/方法”的互斥访问。
- synchronized修饰静态方法以及同步代码块的synchronized (类.class)用法锁的是类,线程想要执行对应同步代码,需要获得类锁。
- synchronized修饰成员方法,线程获取的是当前调用该方法的对象实例的对象锁。
synchronized 和 volatile 区别
- volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好。
- 多线程访问volatile关键字不会发生阻塞,而synchronized关键字可能会发生阻塞
- volatile关键字能保证数据的可见性,但不能保证数据的原子性。synchronized关键字两者都能保证。
- volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized关键字解决的是多个线程之间访问资源的同步性。
Synchronized 和 Lock 区别
synchronized当它用来修饰一个方法或者一个代码块的时候,能够保证在同一时刻最多只有一个线程执行该段代码。
Lock是一个接口,而synchronized是Java中的关键字,synchronized是内置的语言实现;
synchronized在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unlock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;
Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断;
通过Lock可以知道有没有成功获取锁,而synchronized却无法办到。
选择:
在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时Lock的性能要远远优于synchronized。
CAS
- CAS 是比较并替换。它当中使用了3个基本操作数:内存地址 V,旧的预期值 A,要修改的新值 B。如果内存位置 V 的值等于预期的 A 值,则将该位置更新为新值B,否则不进行任何操作。许多CAS的操作是自旋的:如果操作不成功,会一直重试,直到操作成功为止。
- 采用的是一种乐观锁的机制,它不会阻塞任何线程,所以在效率上,它会比
synchronized
要高。 - 所以,在并发量非常高的情况下,我们尽量的用同步锁,而在其他情况下,我们可以灵活的采用 CAS 机制。
- 乐观锁是一种更高效的机制,它的原理就是每次不加锁去执行某项操作,如果发生冲突则失败并重试,直到成功为止,其实本质上不算锁,所以很多地方也称之为自旋,乐观锁用到的主要机制就是CAS。
乐观锁和悲观锁
乐观锁和悲观锁是两种思想,用于解决并发场景下的数据竞争问题。
- 乐观锁:乐观锁在操作数据时非常乐观,认为别人不会同时修改数据。因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作,否则执行操作。
- 悲观锁:悲观锁在操作数据时比较悲观,认为别人会同时修改数据。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据。
实现
- 悲观锁的实现方式是加锁,加锁既可以是对代码块加锁(如Java的synchronized关键字),也可以是对数据加锁(如MySQL中的排它锁)。
- 乐观锁的实现方式主要有两种:CAS机制和版本号机制
++线程安全吗?
假设有1000个线程,每个线程都对共享遍历count进行1000次++操作,最终结果始终不会为1000000。
因为一个线程对共享变量进行操作时会将共享变量从主内存加载到自己的工作内存,完成操作后才会将结果保存到主内存。
如果一个线程运算完成后还没有加载到主内存,此时共享变量的值就被另一个线程从主内存进行读取,此时读到的就是脏数据,会覆盖其他数据计算完的值。
- 解决:
- volatile 虽然能保证共享变量的可见性,但是无法保证它的原子性,也就是说其他线程还是会将当前线程的值覆盖。
- 对++操作加同步锁,同一时间只允许一个线程进行操作。
- 使用支持原子操作的类 AtomicInteger, 使用的 CAS 算法,效率比第一种高
Servlet 生命周期
servlet是运行在web服务器如tomcat,jetty这样应用服务器上的一段程序,他可以响应http协议的请求,并且实现用户自己的逻辑,最终将结果返回到用户的客户端(浏览器)。
- 加载和实例化:当客户端第一次请求某个 Servlet 时,Servlet 容器将会根据 web.xml 的配置文件实例化这个 Servlet 类。
- 初始化:当Servlet被实例化后,Servlet容器将调用每个Servlet的init方法来实例化每个实例,执行完init方法之后,Servlet处于“已初始化”状态。
- 请求处理:Servlet 调用 service() 方法来处理客户端的请求。当有多个请求时,Servlet 容器会起多个线程来访问同一个 Servlet 实例的 service() 方法。
- 卸载Servlet:当服务器不再需要Servlet实例或重新装入时,会调用destroy方法,使用这个方法,Servlet可以释放掉所有在init方法申请的资源。
- 通过 JVM 进行垃圾回收
线程池
好处及作用
- (重要)一个线程池中可以有好几个线程,通过复用其中已创建的线程,可以降低资源消耗,提高程序的响应速度;
- (重要)而且可以控制线程最大并发数,提高了线程的可管理性。
- 实现某些与时间相关的功能,如定时执行、周期执行等。
- 隔离线程环境,可以配置独立线程池,将较慢的线程与较快的隔离开,避免相互影响。
- 实现任务线程队列缓冲策略和拒绝机制。
ThreadExecutor 线程池的参数
① corePoolSize:常驻核心线程数,设置过大会浪费资源,过小会导致线程的频繁创建与销毁。
② maximumPoolSize:线程池能够容纳同时执行的线程最大数,必须大于 0。
③ keepAliveTime:当线程池中线程数量超过corePoolSize时,允许等待多长时间从workQueue中拿任务,超过这个时间会被销毁,避免浪费内存资源。
④ unit:keepAliveTime 的时间单位。
⑤ workQueue:工作队列/阻塞队列,当线程请求数大于等于 corePoolSize 时线程会进入队列。
⑥ threadFactory:线程工厂,用来创建的线程。可以给线程命名,有利于分析错误。
⑦ rejectHandler:线程池中线程超过maximumPoolSize时采用
- 拒绝处理任务时的策略
默认使用 AbortPolicy 丢弃任务并抛出RejectedExecutionException异常;
CallerRunsPolicy 重新尝试提交任务(由调用线程(提交任务的线程)处理该任务);
DiscardOldestPolicy 丢弃队列最前面的任务,然后重新提交被拒绝的任务;
DiscardPolicy 丢弃任务但不抛出异常。
线程池处理任务的流程
简述:
首先判断核心线程池(corePoolSize)里是否满了,如果没满则直接从核心线程池中创建一个线程来执行
如果核心线程满了就判断工作队列(workQueue)是否满了,没满的话提交任务到工作队列等待执行
如果工作队列满了就判断整个线程池是否满了(maximumPoolSize),如果满了就执行决绝策略,否则就创建一个新线程用于执行任务。
原理:
① 创建一个线程池,在还没有任务提交的时候,默认线程池里面是没有线程的。也可以调用prestartCoreThread方法,来预先创建一个核心线程。
② 线程池里还没有线程或者线程池里存活的线程数小于核心线程数(workCount < corePoolSize)时,这时对于一个新提交的任务,线程池会创建一个线程去处理提交的任务。此时线程池里面的线程会一直存活着,就算空闲时间超过了keepAliveTime,线程也不会被销毁,而是一直阻塞在那里一直等待任务队列的任务来执行。
③当线程池里面存活的线程数已>=corePoolSize了,对于一个新提交的任务,会被放进阻塞队列排队等待执行。而之前创建的线程并不会被销毁,而是不断的去拿阻塞队列里面的任务。
当任务队列为空时,线程会阻塞,直到有任务被放进任务队列,线程拿到任务后继续执行,执行完了过后会继续去拿任务。这也是为什么线程池队列要是用阻塞队列。
④ 当线程池里面存活的线程数已经等于corePoolSize了,并且任务队列也满了,(这里假设maximumPoolSize>corePoolSize)这时如果再来新的任务,线程池就会继续创建新的线程来处理新的任务,直到线程数达到maximumPoolSize,就不会再创建了。
这些新创建的线程执行完了当前任务过后,在任务队列里面还有任务的时候也不会销毁,而是去任务队列拿任务出来执行。在当前线程数大于corePoolSize过后,线程执行完当前任务,会有一个判断当前线程是否需要销毁的逻辑:如果能从任务队列中拿到任务,那么继续执行,如果拿任务时阻塞(线程处于空闲状态),那超过keepAliveTime时间就直接返回null并且销毁当前线程,直到线程池里面的线程数等于corePoolSize之后才不会进行线程销毁。
⑤ 如果当前的线程数达到了maximumPoolSize,并且任务队列也满了,这种情况下还有新的任务过来,那就直接采用拒绝策略进行处理。默认的处理器逻辑是使用AbortPolicy抛出一个RejectedExecutionException异常。
创建线程池
可以通过 Executors 的静态工厂方法创建四类线程池:
① newFixedThreadPool
,固定大小的线程池,核心线程数也是最大线程数,不存在空闲线程,keepAliveTime = 0。使用的工作队列是无界阻塞队列 LinkedBlockingQueue,适用于负载较重的服务器。
② newSingleThreadExecutor
,使用单线程,相当于单线程串行执行所有任务,所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。适用于需要保证顺序执行任务的场景。
③ newCachedThreadPool
,创建一个可缓存线程池,如果线程池长度超过处理需要,可以灵活回收空闲线程,若没有可回收的空闲线程,就会新建线程。如果主线程提交任务的速度高于线程处理的速度,线程池可能会不断创建新线程,极端情况下会耗尽 CPU 和内存资源。适用于执行很多短期异步任务的小程序或负载较轻的服务器。
④ newScheduledThreadPool
:定长线程池,支持定期及周期性任务执行,适用需要多个后台执行任务,同时限制线程数量的场景。相比 Timer 更安全,功能更强,与 newCachedThreadPool
的区别是不回收工作线程。
线程池状态及流转
RUNNING: 运行中,接收新的任务或处理队列中的任务。
SHUTDOWN: 关闭,不再接收新的任务,但会处理队列中的任务。
STOP: 停止,不再接收新的任务,也不处理队列中的任务,并中断正在处理的任务。
TIDYING:所有任务已结束,队列大小为0。
关闭线程池, shutdown 和 shutdownNow 区别
- 可以调用
shutdown
或shutdownNow
方法关闭线程池,原理是遍历线程池中的工作线程,逐个调用interrupt
方法中断线程。 - 区别是
shutdownNow
首先将线程池的状态设为 STOP,然后尝试停止线程池中所有的线程,并返回等待执行任务的列表;而shutdown
只是将线程池的状态设为 SHUTDOWN,工作队列中的任务继续执行,同时线程池会向那些空闲的线程发出中断信号。 - 通常调用
shutdown
来关闭线程池,如果任务不一定要执行完可调用shutdownNow
。
所有任务执行完后,不调用shutdown或shutdownNow方法可能存在的问题
如果没有设置核心线程允许超时将会有问题。
- 核心线程允许超时是指在从wokerQueue中获取任务时,采用的阻塞的获取方式等待任务到来,还是通过设置超时的方式从同步阻塞队列中获取任务。
- 如果不调用shutdown或shutdownNow方法,核心线程由于在getTask方法调用BlockingQueue.take方法获取任务而处于一直被阻塞挂起状态。核心线程将永远处于Blocking的状态,导致内存泄漏,主线程也无法退出,除非强制kill。
线程池的选择策略
- 任务性质:CPU 密集型、IO 密集型和混合型。
性质不同的任务用不同规模的线程池处理,CPU 密集型任务应配置尽可能少的线程;IO 密集型任务应配置尽可能多的线程;混合型任务,如果可以拆分,将其拆分为一个 CPU 密集型任务和一个 IO 密集型任务,只要两个任务执行时间相差不大,那么分解后的吞吐量将高于串行执行的吞吐量,如果相差太大则没必要分解。 - 任务优先级/执行时间。
使用优先级队列让优先级高或执行时间短的任务先执行。 - 任务依赖性:是否依赖其他资源,如数据库连接。
依赖数据库连接池的任务,由于线程提交 SQL 后需要等待数据库返回的结果,等待的时间越长 CPU 空闲的时间就越长,因此线程数应该尽可能地设置大一些,提高 CPU 的利用率。
线程池创建的时间点
- 一般在任务被提交后,线程池会利用线程工厂去创建线程,除非线程池中线程数已为corePoolSize或maxmumPoolSize。
- 可以在任务提交前通过prestartCoreThread方法或prestartAllCoreThreads方法预先创建核心线程。
ThreadPoolExecutor竟然是线程池那么他是如何做到重复利用线程的?
一旦线程池通过ThreadFactory创建好线程后,就会将创建的线程封装到了Worker对象中,同时启动该线程。新创建的线程会执行刚提交的任务,同时会不断地从workerQueue中取出任务执行。线程池的线程复用正是通过不断地从workerQueue中取出任务来执行达到的。
如何保证同一时间点一个线程只执行一个任务?
通过Worker类中继承的AbstractQueuedSynchronizer,实现了排它锁。每次在执行提交任务时都会先lock操作,执行完任务后再做unlock操作。
ThreadPoolExecutor 有没有提供扩展点,方便在任务执行前或执行后做一些事情?
线程池提供了三个扩展点,分别是提交任务的run方法或是call方法被调用前与被调后,即beforeExecutor与afterExecutor方法;另外一个扩展点是线程池的状态从TIDYING状态流转为TERMINATED状态时,terminated方法会被调用。
阻塞队列/工作队列
阻塞队列支持阻塞插入和移除,当队列满时,阻塞生产线程直到队列不满。当队列为空时,消费线程会被阻塞直到队列非空。阻塞生产者主要通过 LockSupport 的 park
方法实现,不同操作系统中实现方式不同,在 Linux 下使用的是系统方法 pthread_cond_wait
实现。
Java 中的阻塞队列
- ArrayBlockingQueue,由数组组成的有界阻塞队列,默认情况下不保证线程公平。
- LinkedBlockingQueue,由链表组成的有界阻塞队列,队列的默认和最大长度为 Integer 最大值。
- PriorityBlockingQueue,支持优先级的无界阻塞队列,默认情况下元素按升序排序。可自定义
compareTo
方法指定排序规则,或者初始化时指定 Comparator 排序,不能保证同优先级元素的顺序。 - DelayQueue,支持延时获取元素的无界阻塞队列,使用优先级队列实现。创建元素时可以指定多久才能从队列中获取当前元素,只有延迟期满时才能从队列中获取元素,适用于缓存和定时调度。
- SynchronousQueue,不存储元素的阻塞队列,每一个 put 必须等待一个 take。默认使用非公平策略,适用于传递性场景,吞吐量高。
- LinkedBlockingDeque,链表组成的双向阻塞队列,可从队列的两端插入和移出元素,多线程同时入队时减少了竞争。
ThreadLocal
ThreadLocal 是线程共享变量,主要用于线程内跨类、方法传递数据。ThreadLoacl 有一个静态内部类 ThreadLocalMap,其 Key 是 ThreadLocal 对象,值是 Entry 对象,Entry 中只有一个 Object 类的 vaule 值。ThreadLocal 是线程共享的,但 ThreadLocalMap 是每个线程私有的。ThreadLocal 主要有 set、get 和 remove 三个方法。
- set 方法
首先获取当前线程,然后再获取当前线程对应的 ThreadLocalMap 类型的对象 map。如果 map 存在就直接设置值,key 是当前的 ThreadLocal 对象,value 是传入的参数。
如果 map 不存在就通过createMap
方法为当前线程创建一个 ThreadLocalMap 对象再设置值。 - get 方法
首先获取当前线程,然后再获取当前线程对应的 ThreadLocalMap 类型的对象 map。如果 map 存在就以当前 ThreadLocal 对象作为 key 获取 Entry 类型的对象 e,如果 e 存在就返回它的 value 属性。
如果 e 不存在或 map 不存在,就调用setInitialValue
方法为当前线程创建一个 ThreadLocalMap 对象然后返回默认的初始值 null。 - remove 方法
首先通过当前线程获取其对应的 ThreadLocalMap 类型的对象 m,如果 m 不为空,就解除 ThreadLocal 这个 key 及其对应的 value 值的联系。 - 存在的问题
线程复用会产生脏数据,由于线程池会重用 Thread 对象,因此与 Thread 绑定的 ThreadLocal 也会被重用。如果没有调用 remove 清理与线程相关的 ThreadLocal 信息,那么假如下一个线程没有调用 set 设置初始值就可能 get 到重用的线程信息。
ThreadLocal 还存在内存泄漏的问题,由于 ThreadLocal 是弱引用,但 Entry 的 value 是强引用,因此当 ThreadLocal 被垃圾回收后,value 依旧不会被释放。因此需要及时调用 remove 方法进行清理操作。
Spring
优点
1.降低了组件之间的耦合性 ,实现了软件各层之间的解耦
2.可以使用容易提供的众多服务,如事务管理,消息服务等
3.容器提供单例模式支持
4.容器提供了AOP技术,利用它很容易实现如权限拦截,运行期监控等功能
5.容器提供了众多的辅助类,能加快应用的开发
6.spring对于主流的应用框架提供了集成支持,如hibernate,JPA,Struts等
7.spring属于低侵入式设计,代码的污染极低
8.独立于各种应用服务器
9.spring的DI机制降低了业务对象替换的复杂性
10.Spring的高度开放性,并不强制应用完全依赖于Spring,开发者可以自由选择spring的部分或全部
Spring IoC⭐
IoC
IoC 控制反转,把对象创建、依赖反转给容器实现,需要创建一个容器和一种描述让容器知道对象间的依赖关系,Spring 通过 IoC 容器管理对象及其依赖关系。IoC 的主要实现方式是 DI,对象不是从容器中查找依赖的类,而是容器实例化对象时主动为它注入依赖的类。
IoC叫控制反转,DI叫依赖注入。
- 控制反转就是对组件对象控制权的转移,从程序代码本身转移到了外部容器,由容器来创建对象并管理对象之间的依赖关系。
- 依赖注入的基本原则是应用组件不应该负责查找资源或者其他依赖的协作对象。配置对象的工作应该由容器负责,查找资源的逻辑应该从应用组件的代码中抽取出来,交给容器来完成。DI是对IoC更准确的描述,即组件之间的依赖关系由容器在运行期决定,即由容器动态的将某种依赖关系注入到组件之中。
DI
实现
- 构造方法注入
IoC 容器会检查对象的构造方法,取得它的依赖对象列表,当对象实例化完成时依赖的属性也会成功注入,可以直接使用。缺点是当依赖对象较多时,可能需要多个构造方法。 - setter 方法注入
只需要为依赖对象的属性添加 setter 方法,在描述性上要比构造方法注入强,缺点是无法在对象构造完成后就进入就绪状态。IoC 容器会先实例化 Bean 对象,然后通过反射调用 setter 方法注入属性。 - 注解注入
@Autowired
:自动按类型注入,如果有多个匹配则按照指定 Bean 的 id 查找,需要搭配@Qualifier
。@Resource
:按照 Bean 的 id 注入,如果找不到则会按类型注入。@Value
:用于注入基本数据类型和 String。
动态代理
动态代理可以随时为任意的委托类进行代理,并可以在InvocationHandler#invoke
拿到运行时的信息,并可以做一些切面处理。
在动态代理背后,其实是为一个委托类动态生成了一个Proxy.class
的代理类,该代理类会实现委托类的接口,并把接口调用转发到InvocationHandler#invoke
上,最终调用到真实委托类的对应方法。
动态代理机制把委托类和代理类进行了隔离,提高了扩展性。
Spring AOP ⭐
AOP
对于面向对象编程的语言来说,当需要为部分对象引入公共部分的时候,就会引入大量的重复代码【这些代码我们可以称之为横切代码】。
AOP 面向切面编程就是解决这个问题的,可以将代码中重复的部分抽取出来,使用动态代理技术,在不修改源码的基础上对方法进行增强。
使用 AOP 便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可操作性和可维护性。
如果目标对象实现了接口,默认采用 JDK 动态代理,也可以强制使用 CGLib;如果目标对象没有实现接口,采用 CGLib 的方式。
常用场景包括权限认证、自动缓存、错误处理、日志、调试和事务等。
使用案例:JDBCTemplate连接数据库以及声明事务,在具体的方法上使用@Transactional
注解开启事务。
相关注解
@Aspect
:声明被注解的类是一个切面 Bean。@Before
:前置通知,指在某个连接点之前执行的通知。@After
:后置通知,指某个连接点退出时执行的通知(不论正常返回还是异常退出)。@AfterReturning
:返回后通知,指某连接点正常完成之后执行的通知,返回值使用 returning 属性接收。@AfterThrowing
:异常通知,指方法异常退出时执行的通知,和 @AfterReturning
只会有一个执行,异常使用 throwing 属性接收。
Spring MVC ⭐
- Spring MVC是一个基于MVC架构的用来简化web应用程序开发的应用开发框架
处理流程
Web 容器启动时初始化 IoC 容器,加载 Bean 的定义信息并初始化所有单例 Bean,遍历容器中的 Bean,获取每个 Controller 中的所有方法访问的 URL,将 URL 和对应的 Controller 保存到一个 Map 集合中。
所有的请求会转发给 DispatcherServlet 处理,DispatcherServlet 会请求 HandlerMapping 找出容器中被 @Controler
修饰的 Bean 以及被 @RequestMapping
修饰的方法和类,生成 Handler 和 HandlerInterceptor 并以一个 HandlerExcutionChain 链的形式返回。
DispatcherServlet 使用 Handler 找到对应的 HandlerApapter,通过 HandlerApapter 调用 Handler 的方法,将请求参数绑定到方法的形参上,执行方法处理请求并得到逻辑视图 ModelAndView。
使用 ViewResolver 解析 ModelAndView 得到物理视图 View,进行视图渲染,将数据填充到视图中并返回给客户端。
组件
DispatcherServlet
:前端控制器,整个流程控制的核心,负责接收请求并转发给对应的处理组件。Handler
:处理器,完成具体业务逻辑。HandlerMapping
:处理器映射器,完成 URL 到 Controller 映射。HandlerInterceptor
:处理器拦截器,如果需要完成拦截处理可以实现该接口。HandlerExecutionChain
:处理器执行链,包括 Handler 和 HandlerInterceptor。HandlerAdapter
:处理器适配器,DispatcherServlet 通过 HandlerAdapter 来执行不同的 Handler。ModelAndView
:逻辑视图,装载模型数据信息。ViewResolver
:视图解析器,将逻辑视图解析为物理视图。
相关注解
@RequtestMapping
:将 URL 请求和方法映射起来,在类和方法定义上都可以添加。value
属性指定 URL 请求的地址。method
属性限制请求类型,如果没有使用指定方法请求 URL,会报 405 错误。params
属性限制必须提供的参数。@RequestParam
:如果 Controller 方法的形参和 URL 参数名不一致可以使用该注解绑定。value
属性表示 HTTP 请求中的参数名,required
属性设置参数是否必要,默认false。defaultValue
属性指定没有给参数赋值时的默认值。@PathVariable
:Spring MVC 支持 RESTful 风格 URL,通过 @PathVariable
完成参数绑定。
SpringBoot⭐
优点
- 简化开发:它的作用就是快速搭建 Spring 框架。
- 简化配置:比如要创建一个 web 项目,在使用 Spring 的时候,需要在 pom 文件中添加多个依赖,而在 SpringBoot 中只需要添加一个 starter-web 依赖即可。
- 简化部署:使用 Spring 时需要部署 tomcat,然后把项目打成 war 包。而 SpringBoot 内嵌了 tomcat,只需要将项目打成 jar 包即可。
注解
@SpringBootApplication
:自动给程序进行必要配置,这个配置等同于:@Configuration
,@EnableAutoConfiguration
和@ComponentScan
三个配置。@EnableAutoConfiguration
:允许 SpringBoot 自动配置注解,开启后 SpringBoot 就能根据当前类路径下的包或者类来配置 Bean。@SpringBootConfiguration
:相当于@Configuration
,只是语义不同。
Spring, Spring MVC和Spring Boot区别
- Spring MVC和Spring Boot都属于Spring,Spring MVC 是基于Spring的一个 MVC 框架,而Spring Boot 是基于Spring的一套快速开发整合包
- Spring 就像一个大家族,有众多衍生产品例如 Boot,Security,JPA等等。但他们的基础都是Spring 的 IOC 和 AOP,IOC提供了依赖注入的容器,而AOP解决了面向切面的编程,然后在此两者的基础上实现了其他衍生产品的高级功能;Spring MVC是基于 Servlet 的一个 MVC 框架,主要解决 WEB 开发的问题,因为 Spring 的配置非常复杂,各种xml,properties处理起来比较繁琐。于是为了简化开发者的使用,Spring社区创造性地推出了Spring Boot,它遵循约定优于配置,极大降低了Spring使用门槛,但又不失Spring原本灵活强大的功能。
JDBC连接过程
- 加载驱动: 这通过
java.lang.Class
类的静态方法forName(String className)
实现。 - 创建数据库的连接:使用
DriverManager
的getConnection(String url , String username , String password )
方法传入指定的欲连接的数据库的路径、数据库的用户名和密码来获得一个Connection对象 - 创建一个Statement对象:静态SQL语句,Statement;动态SQL语句,PreparedStatement;数据库存储过程,CallableStatement。
- 执行SQL语句
- 遍历结果集
- 关闭JDBC对象资源
- 在这个过程中,需要处理异常
参考
知识点的整理使用到了以下参考:
主要参考
【备战秋招】高质量 Java知识点整理1:算法、设计模式、Java 基础
【备战秋招】高质量 Java知识点整理2:集合、JVM、并发
Java面经汇总【背诵版】囊括了基本所有考点【自己总结的】
深入浅出Java线程池
辅助参考
随笔分类 - Java
Java hashCode() 和 equals()的若干问题解答
Java HashSet实现原理详解
美团面试题:Hashmap的结构,1.7和1.8有哪些区别,史上最深入的分析
什么是守护线程?
面试官最爱的volatile关键字
必学十大经典排序算法,看这篇就够了(附完整代码动图优质文章)
JDK1.7和JDK1.8中HashMap为什么是线程不安全的?
深入解析 ConcurrentHashMap 实现内幕,吊打面试官
HashMap、 ConcurrentHashMap 详解
JVM 系列文章之 Full GC 和 Minor GC
深入理解JVM(3)——7种垃圾收集器
本作品采用《CC 协议》,转载必须注明作者和本文链接
自顶一下,希望好东西被看到嘿嘿
不错,值得一看