Java 基础
以下代码执行结果?
示例1:
public static void main(String[] args) {int a = 0;Integer b = 0;String c = "0";String d = new String("0");change(a, b, c, d);System.out.println(a + "|" + b + "|" + c + "|" + d);
}public static void change(int a, Integer b, String c, String d) {a++;b++;c += "0";d += "0";
}// 输出结果
0|0|0|0
示例2:
public static void main(String[] args) {switchTest("aaa");switchTest("bbb");switchTest("ccc");switchTest("ddd");
}public static void switchTest(String str) {switch (str) {case "aaa":System.out.println("aaa");case "bbb":System.out.println("bbb");break;case "ccc":System.out.println("ccc");default:System.out.println("default");}
}// 输出
aaa
bbb
bbb
ccc
default
default
示例3:
@Test
public void test() {short s1 = 1;s1 += 1;System.out.println(s1);short s2 = 1;s2 = (s2 +1);System.out.println(s2);
}// 以上代码编译会报错,修改为如下:
@Test
public void test() {short s1 = 1;s1 += 1;System.out.println(s1);short s2 = 1;s2 = (short) (s2 +1);System.out.println(s2);
}// 输出
2
2
什么是 Java 虚拟机?
Java 虚拟机是一个可以执行 Java 字节码的虚拟机进程,Java 源文件被编译成能被 Java 虚拟机执行的字节码文件。
Java 被设计成允许应用程序可以运行在任意的平台,而不需要程序员为每一个平台单独重写或者是重新编译。Java 虚拟机让这个变为可能,因为它知道底层硬件平台的指令长度和其他特性。
JDK、JRE、JVM 关系?
JDK:JDK 即为 Java 开发工具包,包含编写 Java 程序所必须的编译、运行等开发工具以及 JRE。开发工具如:
- 用于编译 Java 程序的 javac 命令。
- 用于启动 JVM 运行 Java 程序的 Java 命令。
- 用于生成文档的 Javadoc 命令。
- 用于打包的 jar 命令等等。
JRE:JRE 即为 Java 运行环境,提供了运行 Java 应用程序所必须的软件环境,包含有 Java 虚拟机(JVM)和丰富的系统类库。系统类库即为 Java 提前封装好的功能类,只需拿来直接使用即可,可以大大的提高开发效率。
JVM:JVM 即为 Java 虚拟机,提供了字节码文件(.class)的运行环境支持。
简单说,就是 JDK 包含 JRE 包含 JVM,JRE 包含 JVM。
Java 的数据类型?
类型 | 类型名称 | 占用字节 | 二进制位数 | 取值范围 | |
基本数据类型 | 整形 | byte | 1字节 | 8位 | -27 ~ 27-1 |
short | 2字节 | 16位 | -215 ~ 215-1 | ||
int | 4字节 | 32位 | -231 ~ 231-1 | ||
long | 8字节 | 64位 | -263 ~ 263-1 | ||
浮点 | float | 4字节 | 32位 | 3.402823e+38 ~ 1.401298e-45 | |
double | 8字节 | 64位 | 1.797693e+308~ 4.9000000e-324 | ||
字符型 | char | 2字节 | 16位 | 0~216-1 | |
布尔型 | boolean | true/false | |||
引用数据类型 | 类 | class | |||
接口 | interface | ||||
数组 | Array |
什么是自动拆装箱?
自动装箱是 Java 编译器在基本数据类型和对应的对象包装类型之间做的一个转化。比如:把 int 转化成 Integer,double 转化成 Double,等等。反之就是自动拆箱。
Java 数据类型转换?
- 自动类型转换:自动类型转换就是隐式转换,把一个小类型的数据赋值给大类型的数据;
- 两种类型是彼此兼容的,转换后的目标类型占的空间范围一定要大于被转化的源类型,即由低字节向高字节自动转换。
- 强制数据转换:强制类型转换就是显式转换,把一个大类型的数据强制赋值给小类型的数据;
- 将占用字节更大的数据类型转换成一个占用字节更小的数据类型,可能存在精度损失的风险,此时就需要进行强制类型转换,如int a = (int)3.14。
- 数据类型自动提升
- 如果两个操作数其中有一个是 double 类型,另一个操作就会转换为 double 类型。
- 否则,如果其中一个操作数是 float 类型,另一个将会转换为 float 类型。
- 否则,如果其中一个操作数是 long 类型,另一个会转换为 long 类型。
- 否则,两个操作数都转换为 int 类型。
示例:float f=3.4; 不正确。3.4 是双精度数(double),将双精度型(double)赋值给浮点型(float)属于 下转型(down-casting,也称为窄化)会造成精度损失,因此需要强制类型转换float f =(float)3.4; 或者写成 float f =3.4F;。
形参与实参区别?
(1)实参(argument)
全称为"实际参数"是在调用时传递给函数的参数. 实参可以是常量、变量、表达式、函数等, 无论实参是何种类型的量,在进行函数调用时,它们都必须具有确定的值, 以便把这些值传送给形参。 因此应预先用赋值,输入等办法使实参获得确定值。
(2)形参(parameter)
全称为"形式参数" 由于它不是实际存在变量,所以又称虚拟变量。是在定义函数名和函数体的时候使用的参数,目的是用来接收调用该函数时传入的参数.在调用函数时,实参将赋值给形参。因而,必须注意实参的个数,类型应与形参一一对应,并且实参必须要有确定的值。
(3)形参与实参区别
- 形参出现在函数定义中,在整个函数体内都可以使用, 离开该函数则不能使用。实参出现在主调函数中,进入被调函数后,实参变量也不能使用。
- 形参变量只有在被调用时才分配内存单元,在调用结束时, 即刻释放所分配的内存单元。因此,形参只有在函数内部有效。 函数调用结束返回主调函数后则不能再使用该形参变量。
- 实参可以是常量、变量、表达式、函数等, 无论实参是何种类型的量,在进行函数调用时,它们都必须具有确定的值, 以便把这些值传送给形参。 因此应预先用赋值,输入等办法使实参获得确定值。
- 实参和形参在数量上,类型上,顺序上应严格一致, 否则会发生“类型不匹配”的错误。
- 函数调用中发生的数据传送是单向的。 即只能把实参的值传送给形参,而不能把形参的值反向地传送给实参。 因此在函数调用过程中,形参的值发生改变,而实参中的值不会变化。
- 当形参和实参不是指针类型时,在该函数运行时,形参和实参是不同的变量,他们在内存中位于不同的位置,形参将实参的内容复制一份,在该函数运行结束的时候形参被释放,而实参内容不会改变。
- 而如果函数的参数是指针类型变量,在调用该函数的过程中,传给函数的是实参的地址,在函数体内部使用的也是实参的地址,即使用的就是实参本身。所以在函数体内部可以改变实参的值。
static 关键字?
static 关键字表明一个成员变量或者是成员方法可以在没有所属的类的实例变量的情况下被访问。
static 方法是否可以被覆盖:
static 方法不能被覆盖,因为方法覆盖是基于运行时动态绑定的,而 static 方法是编译时静态绑定的。static 方法跟类的任何实例都不相关,所以概念上不适用。
static 中能否访问非 static 变量:
static 变量在 Java 中是属于类的,它在所有的实例中的值是一样的。当类被 Java 虚拟机载入的时候,会对 static 变量进行初始化。如果你的代码尝试不用实例来访问非 static 的变量,编译器会报错,因为这些变量还没有被创建出来,还没有跟任何实例关联上。
String 类?
String 类源码:
public final class Stringimplements java.io.Serializable, Comparable<String>, CharSequence {...
}
- String 类是 final 型,所以 String 类不能被继承,它的成员方法也都默认为 final 方法。String对象一旦创建就固定不变了,对 String 对象的任何改变都不影响到原对象,相关的任何改变操作都会生成新的 String 对象。
- String 类是通过 char 数组来保存字符串的,String 对 equals 方法进行了重定,比较的是值相等。
String 为什么不可变:
- 字符串常量池需要 String 不可变。因为 String 设计成不可变,当创建一个 String 对象时,若此字符串值已经存在于常量池中,则不会创建一个新的对象,而是引用已经存在的对象。如果字符串变量允许必变,会导致各种逻辑错误,如改变一个对象会影响到另一个独立对象。
- String 对象可以缓存 hashCode。字符串的不可变性保证了 hash 码的唯一性,因此可以缓存 String 的 hashCode,这样不用每次去重新计算哈希码。在进行字符串比较时,可以直接比较 hashCode,提高了比较性能;
- 安全性。String 被许多 java 类用来当作参数,如 url 地址,文件 path 路径,反射机制所需的 String 参数等,若 String 可变,将会引起各种安全隐患。
String、StringBuffer、StringBuilder?
三个类继承结构如下:
(1)区别
- String 是不可变字符串,每次修改会创建新的对象。
- StringBuffer 是可变字符串,每次修改不会创建新的对象,效率低,线程安全。
- StringBuilder 是可变字符串,每次修改不会创建新的对象,效率高,线程不安全。
- String 可以赋空值 null,StringBuffer 和 StringBuilder 不可以赋空值,如下:
// 不可以,会报错
StringBuffer stringBuffer1 ="";
StringBuilder stringBuilder1 ="";// 可以
StringBuffer stringBuffer2 =null;
StringBuilder stringBuilder2 =null;
String string1 = "";
String string2 = null;
(2)线程安全
StringBuffer 线程安全,因为 StringBuffer 的所有公开方法都是 synchronized 修饰的,源码如下:
public final class StringBufferextends AbstractStringBuilderimplements java.io.Serializable, CharSequence {...@Overridepublic synchronized int length() {return count;}@Overridepublic synchronized int capacity() {return value.length;}@Overridepublic synchronized void ensureCapacity(int minimumCapacity) {super.ensureCapacity(minimumCapacity);}@Overridepublic synchronized void trimToSize() {super.trimToSize();}...
}
StringBuilder 线程不安全,是因为其类中的方法没有用 synchronized 修饰的,如下:
public final class StringBuilderextends AbstractStringBuilderimplements java.io.Serializable, CharSequence{...@Overridepublic StringBuilder append(Object obj) {return append(String.valueOf(obj));}@Overridepublic StringBuilder append(String str) {super.append(str);return this;}...
}
(3)缓冲区
StringBuffer 和 StringBuilder 初始化时会构造一个字符缓冲区,初始容量为16个字符,源码如下:
// StringBuffer 初始化
public StringBuffer() {super(16);
}// StringBuilder 初始化
public StringBuilder() {super(16);
}
toString() 方法源码:
// StringBuffer
@Override
public synchronized String toString() {if (toStringCache == null) {toStringCache = Arrays.copyOfRange(value, 0, count);}return new String(toStringCache, true);
}// StringBuilder
@Override
public String toString() {// Create a copy, don't share the arrayreturn new String(value, 0, count);
}
- StringBuffer 每次获取 toString 都会直接使用缓存区的 toStringCache 值来构造一个字符串。
- StringBuilder 则每次都需要复制一次字符数组,再构造一个字符串。
(4)使用选择
- 如果要操作少量的数据用 String;
- 多线程操作字符串缓冲区下操作大量数据 StringBuffer;
- 单线程操作字符串缓冲区下操作大量数据 StringBuilder(推荐使用)。
深拷贝和浅拷贝?
(1)普通拷贝
普通拷贝即引用拷贝,只是将对象的地址拷贝给另一个对象,即两个对象都指向同一个内存地址,当其中一个对象的值或者属性值发生变化,另一个对象的值或者属性也会跟着变化,示例如下:
// 实体类
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Users {private Integer id;private String name;private int age;
}// 测试代码
@Test
public void test1() {Users users1 = new Users(1,"张三",19);Users users2 = users1;System.out.println(users1 == users2);System.out.println("users1="+users1);System.out.println("users2="+users2);System.out.println("---------------");users1.setName("李四");System.out.println("users1="+users1);System.out.println("users2="+users2);System.out.println("---------------");users2.setId(10);System.out.println("users1="+users1);System.out.println("users2="+users2);
}// 输出结果
true
users1=Users(id=1, name=张三, age=19)
users2=Users(id=1, name=张三, age=19)
---------------
users1=Users(id=1, name=李四, age=19)
users2=Users(id=1, name=李四, age=19)
---------------
users1=Users(id=10, name=李四, age=19)
users2=Users(id=10, name=李四, age=19)
(2)浅拷贝
浅拷贝就是使用 Object 类的 clone() 方法完成对象的拷贝,这种拷贝不再是拷贝对象的地址,而是创建一个新的对象,并将原来对象的值或者属性值拷贝给这个新的对象,示例如下:
// 实体类
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Users implements Cloneable {private Integer id;private String name;private int age;@Overridepublic Users clone() {try {return (Users) super.clone();} catch (CloneNotSupportedException e) {return null;}}
}// 测试代码
users1=Users(id=1, name=张三, age=19)
users2=Users(id=1, name=张三, age=19)
---------------
users1=Users(id=1, name=李四, age=19)
users2=Users(id=1, name=张三, age=19)
---------------
users1=Users(id=1, name=李四, age=19)
users2=Users(id=10, name=张三, age=19)
要实现浅拷贝,对象实体类必须实现 Cloneable 接口,并且重写clone()方法。
(3)深拷贝
在浅拷贝中,当拷贝的对象类属性中包含引用类型属性,那么该引用类型属性在浅拷贝时,仍旧只会拷贝内存地址,并且拷贝前后的两个对象中任意一个对象对引用类属性的值进行改变,另一个对象的引用类属性的值也会跟着变化。
深拷贝就是在拷贝时,引用类型的属性也是值拷贝而不是内存地址拷贝,需要在重写的clone()
方法中,不仅拷贝当前对象,也要对对象中的引用类型属性进行拷贝
包含引用类型属性的浅拷贝示例:
// Users 类
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Users implements Cloneable{private Integer id;private String name;// Student 类引用private Students students;@Overridepublic Users clone() {try {return (Users) super.clone();} catch (CloneNotSupportedException e) {return null;}}
}// Student 类
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Students {private Integer id;private String name;
}@Test
public void test1() {Users users1 = new Users(1, "张三", new Students(99, "老六"));Users users2 = users1.clone();System.out.println(users1 == users2);System.out.println("users1=" + users1);System.out.println("users2=" + users2);System.out.println("---------------");users1.setName("李四");users1.getStudents().setName("老铁");System.out.println("users1=" + users1);System.out.println("users2=" + users2);System.out.println("---------------");users2.setId(10);users2.getStudents().setId(88);System.out.println("users1=" + users1);System.out.println("users2=" + users2);
}// 输出结果
false
users1=Users(id=1, name=张三, students=Students(id=99, name=老六))
users2=Users(id=1, name=张三, students=Students(id=99, name=老六))
---------------
users1=Users(id=1, name=李四, students=Students(id=99, name=老铁))
users2=Users(id=1, name=张三, students=Students(id=99, name=老铁))
---------------
users1=Users(id=1, name=李四, students=Students(id=88, name=老铁))
users2=Users(id=10, name=张三, students=Students(id=88, name=老铁))
深拷贝示例:
// Users 类
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Users implements Cloneable {private Integer id;private String name;private Students students;@Overridepublic Users clone() {try {Users users = (Users) super.clone();users.students = this.students.clone();return users;} catch (CloneNotSupportedException e) {return null;}}
}// Student 类
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Students implements Cloneable{private Integer id;private String name;@Overridepublic Students clone() {try {return (Students) super.clone();} catch (CloneNotSupportedException e) {return null;}}
}@Test
public void test1() {Users users1 = new Users(1, "张三", new Students(99, "老六"));Users users2 = users1.clone();System.out.println(users1 == users2);System.out.println("users1=" + users1);System.out.println("users2=" + users2);System.out.println("---------------");users1.setName("李四");users1.getStudents().setName("老铁");System.out.println("users1=" + users1);System.out.println("users2=" + users2);System.out.println("---------------");users2.setId(10);users2.getStudents().setId(88);System.out.println("users1=" + users1);System.out.println("users2=" + users2);
}// 输出结果
false
users1=Users(id=1, name=张三, students=Students(id=99, name=老六))
users2=Users(id=1, name=张三, students=Students(id=99, name=老六))
---------------
users1=Users(id=1, name=李四, students=Students(id=99, name=老铁))
users2=Users(id=1, name=张三, students=Students(id=99, name=老六))
---------------
users1=Users(id=1, name=李四, students=Students(id=99, name=老铁))
users2=Users(id=10, name=张三, students=Students(id=88, name=老六))
equals 与 == 的区别?
(1)基本数据类型
基本数据类型(也称原始数据类型):byte,short,char,int,long,float,double,boolean。他们之间的比较使用双等号(==),比较的是他们的值。
public static void main(String[] args) {int a = 10;int b = 12;int d = 10;int c = a;System.out.println(a == b);System.out.println(a == c);System.out.println(a == d);
}// 输出结果
false
true
true
(2)引用数据类型
引用数据类型使用双等号(==)进行比较的时候,比较的是他们在内存中的存放地址(堆内存地址),而 equals 比较的是值。
public static void main(String[] args) {String a = "hello word";String b = "hello word";String c = new String("hello word");System.out.println(a == b);System.out.println(a == c);System.out.println(a.equals(b));System.out.println(a.equals(c));
}// 输出结果
true
false
true
true
(3)对象数据类型
Java 中所有的类都是继承于 Object 这个超类的,在 Object 类中定义了一个 equals 的方法,源码如下:
public boolean equals(Object obj) {return (this == obj);
}
该 equals 方法的初始默认行为是比较对象的内存地址值,在一些类库中这个方法被重写了,如String。在这些类当中 equals 有其自身的实现(一般都是用来比较对象的成员变量值是否相同),而不再是比较类在堆内存中的存放地址了。
//String 类
public boolean equals(Object anObject) {if (this == anObject) {return true;}if (anObject instanceof String) {String anotherString = (String)anObject;int n = value.length;if (n == anotherString.value.length) {char v1[] = value;char v2[] = anotherString.value;int i = 0;while (n-- != 0) {if (v1[i] != v2[i])return false;i++;}return true;}}return false;
}
对象类比较示例:
public class Users {private String code;private String name;}// 测试代码
public static void main(String[] args) {Users users1 = new Users();Users users2 = new Users();Users users3 = new Users();System.out.println(users1);System.out.println(users2);System.out.println(users3);System.out.println(users1 == users2);System.out.println(users1 == users3);System.out.println(users1.equals(users2));System.out.println(users1.equals(users3));}// 输出结果
com.yyds.model.entity.Users@1c655221
com.yyds.model.entity.Users@58d25a40
com.yyds.model.entity.Users@1b701da1
false
false
false
false
当 Users 类重写 equals 方法如下:
public class Users {private String code;private String name;@Overridepublic boolean equals(Object o) {if (this == o) {return true;}if (o == null || getClass() != o.getClass()) {return false;}Users users = (Users) o;return Objects.equals(code, users.code) && Objects.equals(name, users.name);}@Overridepublic int hashCode() {return Objects.hash(code, name);}
}// 输出结果:
com.yyds.model.entity.Users@3c1
com.yyds.model.entity.Users@3c1
com.yyds.model.entity.Users@3c1
false
false
true
true
总结:
- 基本数据类型,使用 == 进行比较,比较的是值是否一样。
- 引用数据类型,使用 == 进行比较的是引用数据地址是否一致,使用 equals 比较的是引用数据的值是否一致。
- 对象数据类型,没有重写 equals 方法时比较的是对象的内存地址是否一致,重写 equals 方法后比较的是对象的成员变量的值是否一致,或者根据重写规则进行比较。
hashCode() 与 equals() 的关系?
- 如果两个对象的 hashCode 相等,两个对象不一定相等,即a.equals(b)不一定相等;
- 如果两个对象 equals 的值为 true,即a.equals(b)=true的话,那么这两个对象的 hashCode 一定相等。
示例:
@Test
public void test1() {Integer a = 97;String b = "a";System.out.println("a.hashCode()=" + a.hashCode());System.out.println("b.hashCode()=" + b.hashCode());System.out.println("a.equals(b)=" + a.equals(b));
}// 输出结果
a.hashCode()=97
b.hashCode()=97
a.equals(b)=false@Test
public void test2() {String a = "a";String b = "a";System.out.println("a.equals(b)=" + a.equals(b));System.out.println("a.hashCode()=" + a.hashCode());System.out.println("b.hashCode()=" + b.hashCode());
}// 输出结果
a.equals(b)=true
a.hashCode()=97
b.hashCode()=97
内部类与静态内部类的区别?
静态内部类相对与外部类是独立存在的,在静态内部类中无法直接访问外部类中变量、方法。如果要访问的话,必须要 new 一个外部类的对象,使用 new 出来的对象来访问。但是可以直接访问静态的变量、调用静态的方法;
普通内部类作为外部类一个成员而存在,在普通内部类中可以直接访问外部类属性,调用外部类的方法。
如果外部类要访问内部类的属性或者调用内部类的方法,必须要创建一个内部类的对象,使用该对象访问属性或者调用方法。
如果其他的类要访问普通内部类的属性或者调用普通内部类的方法,必须要在外部类中创建一个普通内部类的对象作为一个属性,外同类可以通过该属性调用普通内部类的方法或者访问普通内部类的属性。
如果其他的类要访问静态内部类的属性或者调用静态内部类的方法,直接创建一个静态内部类对象即可。
什么是覆盖和重载?
重载(Overloading):重载是指在一个类中有两个或多个方法名相同、返回类型相同,但是方法参数不同的方法,如下:
public class Demo {public String method1() {return "success";}public String method1(int a) {return "success";}public String method1(int a, String b) {return "success";}
}
覆盖(Overriding):覆盖是指子类继承父类,并对父类的方法进行了重新定义,方法覆盖必须有相同的方法名,参数列表和返回类型。
public class Demo {public int method(int a, int b) {return a + b;}
}public class Demo1 extends Demo {@Overridepublic int method(int a, int b) {return a - b;}
}
接口和抽象类的区别?
接口
public interface TestInterface {/*成员变量默认是 public final,所以 public、final可以省略*/public final String str1 = "str1";public String str2 = "str2";String str3 = "str2";/*成员方法默认是 public abstract,所以 public、abstract可以省略*/public abstract void testMethod1();public void testMethod2();void testMethod3();
}
抽象类
public abstract class TestAbstract {/*成员变量默认是public,所以public可以省略,成员变量可以是final和非final的,生命final时final不可省略*/public final String str1 = "str1";public String str2 = "str2";String str3 = "str2";/*成员方法默认是public、private、protected,方法为private时只能为非abstract且必须要有方法体成员方法可以是abstract和非abstract,声明abstract时abstract不可省略,非abstract时必须要有方法体*/public abstract void testMethod1();private void testMethod2(){};protected abstract void testMethod3();abstract void testMethod4();public void testMethod5(){}void testMethod6(){}
}
接口和抽象类的区别:
- 接口中声明的变量默认都是 final 的,抽象类可以包含非 final 的变量。
- 接口中所有的方法隐含的都是抽象的(abstract),public 和 abstract 声明可以省略,而抽象类则可以同时包含抽象和非抽象的方法,并且非抽象方法必须包含方法体。
- 类可以实现很多个接口,但是只能继承一个抽象类。
- 类如果要实现一个接口,它必须要实现接口声明的所有方法。继承抽象类时,可以不实现抽象类声明的所有方法,当然,在这种情况下,类也必须得声明成是抽象的。
- 抽象类可以实现接口,并且可以不实现接口中声明的方法。
- 接口中的成员函数默认是 public 的,抽象类的成员函数可以是 private,protected 或者是 public。
- 接口是绝对抽象的,不可以被实例化,抽象类也不可以被实例化,但是如果它包含 main 方法的话是可以被调用的。
常见编码方式?
编码的意义:计算机中存储的最小单元是一个字节即 8bit,所能表示的字符范围是 255 个,而人类要表示的符号太多,无法用一个字节来完全表示,固需要将符号编码,将各种语言翻译成计算机能懂的语言。
- ASCII 码:总共 128 个,用一个字节的低 7 位表示,0〜31 控制字符如换回车删除等;32~126是打印字符,可通过键盘输入并显示出来;
- ISO-8859-1:用来扩展 ASCII 编码,256 个字符,涵盖了大多数西欧语言字符。
- GB2312:双字节编码,总编码范围是 A1-A7,A1-A9 是符号区,包含 682 个字符,B0-B7 是汉字区,包含 6763 个汉字;
- GBK:为了扩展 GB2312,加入了更多的汉字,编码范围是 8140~FEFE,有 23940 个码位,能表示 21003 个汉字。
- UTF-16:ISO 试图想创建一个全新的超语言字典,世界上所有语言都可通过这本字典Unicode 来相互翻译,而 UTF-16 定义了 Unicode 字符在计算机中存取方法,用两个字节来表示 Unicode 转化格式。不论什么字符都可用两字节表示,即 16bit,固叫 UTF-16。
- UTF-8:UTF-16 统一采用两字节表示一个字符,但有些字符只用一个字节就可表示,浪费存储空间,而 UTF-8 采用一种变长技术,每个编码区域有不同的字码长度。 不同类型的字符可以由1~6 个字节组成。
Java 内存模型?
Java 内存模型(Java Memory Model)是一种虚拟机规范,分为主内存和工作内存。
- 主内存存储所有的共享变量,工作内存是每个线程私有的。
- 当开启一个线程并需要使用到主内存中的共享变量时,会先将主内存中的共享变量读取到当前线程的工作内存中,然后再从工作内存中加载共享变量并使用。
关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java 内存模型定义了以下八种操作来完成:
- lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
- unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的 load 动作使用
- load(载入):作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的 write 的操作。
- write(写入):作用于主内存的变量,它把 store 操作从工作内存中一个变量的值传送到主内存的变量中。
Java 内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:
- 如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行 read 和 load 操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行 store和 write 操作。但 Java 内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。
- 不允许 read 和 load、store 和 write 操作之一单独出现
- 不允许一个线程丢弃它的最近 assign 的操作,即变量在工作内存中改变了之后必须同步到主内存中。
- 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从工作内存同步回主内存中。
- 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量。即就是对一个变量实施 use 和 store 操作之前,必须先执行过了 assign 和 load 操作。
- 一个变量在同一时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。lock 和 unlock 必须成对出现
- 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行 load 或 assign 操作初始化变量的值
- 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作;也不允许去 unlock 一个被其他线程锁定的变量。
- 对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中(执行 store 和 write 操作)。
Java 内存模型问题:
- 可见性
- 原子性
静态代码块,构造代码块,构造函数执行执行顺序?
静态代码块:
- 它是随着类的加载而执行,只执行一次,并优先于主函数。具体说,静态代码块是由类调用的,类调用时先执行静态代码块,然后才执行主函数的。
- 静态代码块其实就是给类初始化的,而构造代码块是给对象初始化的。
- 静态代码块中的变量是局部变量,与普通函数中的局部变量性质没有区别。
- 一个类中可以有多个静态代码块。
构造代码块:
- 构造代码块的作用是给对象进行初始化。
- 对象一建立就运行构造代码块了,而且优先于构造函数执行。有对象建立,才会运行构造代码块,类不能调用构造代码块的。
- 构造代码块与构造函数的区别是:构造代码块是给所有对象进行统一初始化,而构造函数是给对应的对象初始化,因为构造函数是可以多个的,运行哪个构造函数就会建立什么样的对象,但无论建立哪个对象,都会先执行相同的构造代码块。也就是说,构造代码块中定义的是不同对象共性的初始化内容。
构造函数:
- 对象一建立,就会调用与之相应的构造函数,也就是说,不建立对象,构造函数时不会运行的。
- 构造函数的作用是用于给对象进行初始化。
- 一个对象建立,构造函数只运行一次,而一般方法可以被该对象调用多次。
示例1:
public class TestUtil {static {System.out.println("静态代码块!");}{System.out.println("构造代码块!");}public TestUtil() {System.out.println("构造函数!");}public static void main(String[] args) {TestUtil testUtil = new TestUtil();}
}// 输出结果
静态代码块!
构造代码块!
构造函数!
示例2:
// TestUtil 类
public class TestUtil {static {System.out.println("111-静态代码块!");}{System.out.println("111-构造代码块!");}public TestUtil() {System.out.println("111-构造函数!");}
}// TestUtil2 类继承 TestUtil 类
public class TestUtil2 extends TestUtil {static {System.out.println("222-静态代码块!");}{System.out.println("222-构造代码块!");}public TestUtil2() {System.out.println("222-构造函数!");}public static void main(String[] args) {TestUtil2 testUtil = new TestUtil2();}}// 运行结果
111-静态代码块!
222-静态代码块!
111-构造代码块!
111-构造函数!
222-构造代码块!
222-构造函数!
当涉及到继承时,按照如下顺序执行:
- 执行父类的静态代码块,并初始化父类静态成员变量
- 执行子类的静态代码块,并初始化子类静态成员变量
- 执行父类的构造代码块,执行父类的构造函数,并初始化父类普通成员变量
- 执行子类的构造代码块, 执行子类的构造函数,并初始化子类普通成员变量
Java 集合
Java 包含哪些集合?
- Collection:代表一组对象,每一个对象都是它的子元素。
- Set:不包含重复元素的 Collection。
- HashSet
- TreeSet
- List:有顺序的 collection,并且可以包含重复元素。
- ArrayList
- Vector
- LinkedList
- Set:不包含重复元素的 Collection。
- Map:可以把键(key)映射到值(value)的对象,键不能重复。
- HashMap
- TreeMap
- HashTable
List,Set,Map 的区别?
- List:List 接口存储一组不唯一(可以有多个元素引用相同的对象),有序的对象。
- Set:注重独一无二的性质,不允许重复的集合,不会有多个元素引用相同的对象。
- Map:使用键值对存储,Map 会维护与 Key 有关联的值。两个 Key 可以引用相同的对象,但 Key 不能重复,典型的 Key 是 String 类型,但也可以是任何对象。
ArrayList?
ArrayList 是个动态列表,实现List接口,其特点是:
- 增删慢:每次删除元素,都需要更改数组长度、拷贝以及移动元素位置。
-
查询快:由于数组在内存中是一块连续空间,因此可以根据地址+索引的方式快速获取对应位置上的元素。
-
线程不安全
Array 和 ArrayList 的区别?
- Array 表示数组,ArrayList 表示列表。
- Array 可以包含基本类型和对象类型,ArrayList 只能包含对象类型。
- Array 大小是固定的,ArrayList 的大小是动态变化的。
- ArrayList 提供了更多的方法和特性,比如:
addAll()
,removeAll()
,iterator()
等等。 - 对于基本类型数据,集合使用自动装箱来减少编码工作量。但是,当处理固定大小的基本数据类型的时候,这种方式相对比较慢。
ArrayList、Vector 和 LinkedList?
ArrayList |
|
Vector |
|
LinkedList |
|
ArrayList 线程安全吗?
ArrayList 是线程不安全的。当开启多个线程操作 List 集合,向 ArrayList 中增加元素,同时去除元素。最后输出 list 中的所有数据,会出现几种情况:
- 有些元素输出为Null;
- 数组下标越界异常。
有两种解决方案:
第一种是选用线程安全的数组容器是 Vector,它将所有的方法都加上了 synchronized。
public static Vector<Object> vector= new Vector<Object>();
第二种是用 Collections.synchronizedList
将 ArrayList 包装成线程安全的数组容器。
List<String> list = Collections.synchronizedList(new ArrayList<>());
ArrayList 的扩容机制?
ArrayList 默认数组大小是10。
private static final int DEFAULT_CAPACITY = 10;默认的构造方法,构造一个初始容量为10的空列表。
public ArrayList() {this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
ArrayList 的扩容主要发生在向 ArrayList 集合中添加元素的时候,通过 add()
方法添加单个元素时,会先检查容量,看是否需要扩容。如果容量不足需要扩容则调用 grow()
扩容方法,扩容后的大小等于扩容前大小的1.5倍,也就是10+10/2。比如说超过10个元素时,会重新定义一个长度为15的数组。然后把原数组的数据,原封不动的复制到新数组中,这个时候再把指向原数的地址换到新数组。
grow() 方法源码如下:
private void grow(int minCapacity) {// 记录扩容前的数组长度int oldCapacity = elementData.length;// 位运算,右移动一位。 整体相当于newCapacity =oldCapacity + 0.5 * oldCapacityint newCapacity = oldCapacity + (oldCapacity >> 1);// 如果扩容后的长度小于当前的数据量,那么就将当前的数据量的长度作为本次扩容的长度if (newCapacity - minCapacity < 0)newCapacity = minCapacity;// 判断新数组的长度是否大于可分配数组的最大值if (newCapacity - MAX_ARRAY_SIZE > 0)// 将扩容长度设置为最大可用长度newCapacity = hugeCapacity(minCapacity);// 拷贝,扩容,构建一个新的数组elementData = Arrays.copyOf(elementData, newCapacity);
}
问题:ArrayList<String> list = new ArrayList<>(20); 中的 list 扩充几次?
不需要扩容。当指明了需要多少空间时,会一次性分配这么多的空间,就不需要扩容了。
构造函数源码如下:
public ArrayList(int initialCapacity) {//判断initialCapacity是否大于0if (initialCapacity > 0) {//创建一个数组,且指定长度为initialCapacitythis.elementData = new Object[initialCapacity];} else if (initialCapacity == 0) {//如果initialCapacity容量为0,把EMPTY_ELEMENTDATA的地址赋值给elementDatathis.elementData = EMPTY_ELEMENTDATA;// 如果初始容量小于0,则会出现 IllegalArgumentException 异常} else {throw new IllegalArgumentException("Illegal Capacity: " + initialCapacity);}
}
避免频繁扩容带来的性能影响,就可以在初始化集合时指定集合的大小
如何复制 ArrayList?
(1)使用 clone()
方法
public static void main(String[] args) {ArrayList<String> list = new ArrayList<String>();list.add("aaa");list.add("bbb");Object o = list.clone();System.out.println(o);System.out.println(list);
}
clone() 方法源码:
public Object clone() {try {ArrayList<?> v = (ArrayList<?>) super.clone();v.elementData = Arrays.copyOf(elementData, size);v.modCount = 0;return v;} catch (CloneNotSupportedException e) {// this shouldn't happen, since we are Cloneablethrow new InternalError(e);}
}
(2)使用 ArrayList 构造方法
public static void main(String[] args) {ArrayList<String> list = new ArrayList<String>();list.add("aaa");list.add("bbb");ArrayList<String> list1 = new ArrayList<>(list);for (String s : list1) {System.out.println(s);}
}
构造函数源码:
public ArrayList(Collection<? extends E> c) {// 将集合构造中的集合对象转成数组,且将数组的地址赋值给elementDataelementData = c.toArray();// 将elementData的长度赋值给 集合长度size,且判断是否不等于 0if ((size = elementData.length) != 0) {// 判断elementData 和 Object[] 是否为不一样的类型if (elementData.getClass() != Object[].class)//如果不一样,使用Arrays的copyOf方法进行元素的拷贝elementData = Arrays.copyOf(elementData, size, Object[].class);} else {// 用空数组代替this.elementData = EMPTY_ELEMENTDATA;}
}
(3)使用 addAll()
方法
public static void main(String[] args) {ArrayList<String> list = new ArrayList<>();list.add("aaa");list.add("bbb");ArrayList<String> list1 = new ArrayList<>();list1.addAll(list);System.out.println(list);System.out.println(list1);
}
addAll()
方法源码:
public boolean addAll(Collection<? extends E> c) {//把集合的元素转存到Object类型的数组中Object[] a = c.toArray();//记录数组的长度int numNew = a.length;//调用方法检验是否要扩容,且让增量++ensureCapacityInternal(size + numNew); //调用方法将a数组的元素拷贝到elementData数组中System.arraycopy(a, 0, elementData, size, numNew);//集合的长度+=a数组的长度size += numNew;//只要a数组的长度不等于0,即说明添加成功return numNew != 0;
}
ArrayList 能否用做队列?
队列一般是FIFO(先入先出)的,如果用 ArrayList 做队列,需要在数组尾部追加数据,数组头部删除数组,反过来也可以。但是无论如何总会有一个操作会涉及到数组的数据搬迁,这个是比较耗费性能的。
虽然 ArrayList 不适合做队列,但是数组是非常合适的。比如 ArrayBlockingQueue 内部实现就是一个环形队列,它是一个定长队列,内部是用一个定长数组来实现的。
另外著名的 Disruptor 开源 Library 也是用环形数组来实现的超高性能队列,具体原理不做解释,比较复杂。简单点说就是使用两个偏移量来标记数组的读位置和写位置,如果超过长度就折回到数组开头,前提是它们是定长数组。
迭代器(Iterator)?
Iterator 接口提供了很多对集合元素进行迭代的方法,每一个集合类都包含了可以返回迭代器实例的
迭代方法,迭代器可以在迭代的过程中删除底层集合的元素。
Iterator 和 ListIterator 区别:
- Iterator 可以遍历 Set 和 List 集合,而 ListIterator 只能遍历 List。
- Iterator 只能单向遍历,而 ListIterator 可以双向遍历(向前/后遍历)。
- ListIterator 从 Iterator 接口继承,然后添加了一些额外的功能,比如添加一个元素、替换一个元素、获取前面或后面元素的索引位置。
Enumeration 接口和 Iterator 接口的区别:
- Enumeration 速度是 Iterator 的 2 倍,同时占用更少的内存。
- Iterator 远远比 Enumeration 安全,因为其他线程不能够修改正在被 Iterator 遍历的集合里面的对象。
- Iterator 允许调用者删除底层集合里面的元素,这对 Enumeration 来说是不可能的。
HashSet 的实现原理?
HashSet 是基于 HashMap 实现的,HashSet 底层使用 HashMap 来保存所有元素,因此 HashSet 的实现比较简单,相关 HashSet 的操作,基本上都是直接调用底层 HashMap 的相关方法来完成,HashSet 不允许重复的值。
HashMap?
HashMap 是一个散列表,它存储的内容是键值对(key-value)映射,根据键的 HashCode 值存储数据,最多允许一条记录的键为 null。
HashMap 存储数据是无序的,具有很快的访问速度,但是线程不安全,不支持线程同步。
HashMap 类源码如下:
public class HashMap<K,V> extends AbstractMap<K,V>implements Map<K,V>, Cloneable, Serializable {...
}
HashMap 继承于 AbstractMap,实现了 Map、Cloneable、java.io.Serializable 接口。
HashaMap 的构造方法源码如下:
// 可以指定容量
public HashMap(int initialCapacity) {this(initialCapacity, DEFAULT_LOAD_FACTOR);
}// 默认容量为 16
public HashMap() {this.loadFactor = DEFAULT_LOAD_FACTOR;
}// 初始化 HashMap
public HashMap(int initialCapacity, float loadFactor) {if (initialCapacity < 0)throw new IllegalArgumentException("Illegal initial capacity: " +initialCapacity);if (initialCapacity > MAXIMUM_CAPACITY)initialCapacity = MAXIMUM_CAPACITY;if (loadFactor <= 0 || Float.isNaN(loadFactor))throw new IllegalArgumentException("Illegal load factor: " +loadFactor);this.loadFactor = loadFactor;this.threshold = tableSizeFor(initialCapacity);
}// 部分常量
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
HashMap 的遍历方式?
HashMap 遍历从大的方向来说,可分为以下 4 类:
- 迭代器(Iterator)方式遍历
- For Each 方式遍历
- Lambda 表达式遍历(JDK 1.8+)
- Streams API 遍历(JDK 1.8+)
但每种类型下又有不同的实现方式,因此具体的遍历方式又可以分为以下 7 种:
- 使用迭代器(Iterator)EntrySet 的方式进行遍历
- 使用迭代器(Iterator)KeySet 的方式进行遍历
- 使用 For Each EntrySet 的方式进行遍历
- 使用 For Each KeySet 的方式进行遍历
- 使用 Lambda 表达式的方式进行遍历
- 使用 Streams API 单线程的方式进行遍历
- 使用 Streams API 多线程的方式进行遍历
具体实现方式如下:
// 1.使用迭代器(Iterator)EntrySet 的方式进行遍历
Iterator<Map.Entry<Integer, String>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {Map.Entry<Integer, String> entry = iterator.next();System.out.println("k="+entry.getKey()+" v="+entry.getValue());
}// 2.使用迭代器(Iterator)KeySet 的方式进行遍历
Iterator<Integer> iterator = map.keySet().iterator();
while (iterator.hasNext()) {Integer key = iterator.next();System.out.println("k="+key+" v="+map.get(key));
}// 3.使用 For Each EntrySet 的方式进行遍历
for (Map.Entry<Integer, String> entry : map.entrySet()) {System.out.println("k="+entry.getKey()+" v="+entry.getValue());
}// 4.使用 For Each KeySet 的方式进行遍历
for (Integer key : map.keySet()) {System.out.println("k="+key+" v="+map.get(key));
}// 5.使用 Lambda 表达式的方式进行遍历
map.forEach((key, value) -> {System.out.println("k="+key+" v="+value);
});// 6.使用 Streams API 单线程的方式进行遍历
map.entrySet().stream().forEach((entry) -> {System.out.println("k="+entry.getKey()+" v="+entry.getValue());
});// 7.使用 Streams API 多线程的方式进行遍历
map.entrySet().parallelStream().forEach((entry) -> {System.out.println("k="+entry.getKey()+" v="+entry.getValue());
});
注意:
不能在遍历中使用集合
map.remove()
来删除数据,这是非安全的操作方式,可以使用迭代器的iterator.remove()
的方法来删除数据,这是安全的删除集合的方式。也可以使用 Lambda 中的removeIf
来提前删除数据,或者是使用 Stream 中的filter
过滤掉要删除的数据进行循环,这样都是安全的,当然我们也可以在 for 循环前删除数据在遍历也是线程安全的。
HashMap 的实现原理?
(1)JDK 1.8 之前
JDK1.8 之前 HashMap 底层是数组和链表结合在一起使用也就是链表散列。HashMap 通过 key 的 hashCode()
方法得到 hash 值,然后通过 (n - 1) & hash
判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话就直接覆盖,不相同就通过拉链法解决冲突。
JDK 1.8 HashMap 的 hash 方法源码:
static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
对比一下 JDK1.7的 HashMap 的 hash 方法源码:
static int hash(int h) {h ^= (h >>> 20) ^ (h >>> 12);return h ^ (h >>> 7) ^ (h >>> 4);
}
JDK 1.8 的 hash 方法相比于 JDK 1.7 hash 方法更加简化,但是原理不变。
拉链法” 就是:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
(2)JDK 1.8 之后
相比于之前的版本, JDK1.8 之后在解决哈希冲突时有了较大的变化,底层是用数组+链表+红黑树来实现的。当链表长度大于阈值(默认为8)时,会将链表转化为红黑树,以减少搜索时间。将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树。
TreeMap、TreeSet 以及JDK1.8之后的 HashMap 底层都用到了红黑树。红黑树就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。
HashMap 的 get() 方法源码如下:
// get() 方法
public V get(Object key) {Node<K,V> e;return (e = getNode(hash(key), key)) == null ? null : e.value;
}
get() 方法中调用的 getNode() 方法源码如下:
final Node<K,V> getNode(int hash, Object key) {Node<K,V>[] tab; Node<K,V> first, e; int n; K k;if ((tab = table) != null && (n = tab.length) > 0 &&(first = tab[(n - 1) & hash]) != null) {if (first.hash == hash && // always check first node((k = first.key) == key || (key != null && key.equals(k))))return first;if ((e = first.next) != null) {if (first instanceof TreeNode)return ((TreeNode<K,V>)first).getTreeNode(hash, key);do {if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))return e;} while ((e = e.next) != null);}}return null;
}
get() 方法中调用的 hash() 方法源码如下:
static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
HashMap 的扩容机制?
当 HashMap 中的元素个数超过数组大小 loadFactor 时,就会进行数组扩容。
loadFactor 的默认值为 0.75,数组的默认大小为 16,那么当 HashMap 中元素个数超过 16*0.75=12
的时候,就把数组的大小扩展为 2*16=32
,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知 HashMap 中元素的个数,那么预设元素的个数能够有效的提高 HashMap 的性能。
比如说,我们有 1000 个元素 new HashMap(1000)
,但是理论上来讲 new HashMap(1024)
更合适,不过上面已经说过,即使是 1000,HashMap 也自动会将其设置为 1024。 但是 new HashMap(1024)
还不是更合适的,因为 0.75*1000 < 1000
, 也就是说为了让 0.75 * size > 1000
, 我们必须这样 new HashMap(2048)
才最合适,既考虑了 &
的问题,也避免了 resize的问题。
什么是 hash 冲突?
哈希表:是一种实现关联数组抽象数据类型的数据结构,这种结构可以将关键码映射到给定值。简单来说哈希表(key-value)之间存在一个映射关系,是键值对的关系,一个键对应一个值。
哈希冲突:当两个不同的数经过哈希函数计算后得到了同一个结果,即他们会被映射到哈希表的同一个位置时,即称为发生了哈希冲突(哈希碰撞)。简单来说就是哈希函数算出来的地址被别的元素占用了。
hash 冲突的解决方法:
- 开放定址法:也称线性探测法,从发生 hash 冲突的位置开始,按照一定的顺序从哈希表中找到一个空闲的位置来存放发生 hash 冲突的元素。Java 中 ThreadLocal 就用到了这种方法来解决 hash 冲突。
- 链式寻址法:把存在 hash 冲突的 key 以单向链表的方式来存储,如 HashMap。
- 再 hash 法:对存在 hash 冲突的数据进行用另外一个 hash 方法再次 hash 运算,直到不存在 hash 冲突,这种方式会增加运算时间,性能会有所影响。
- 建立公共溢出区:就是把 hash 表分为基本表和溢出表,凡是存在 hash 冲突的元素都存放在溢出表中,
HashMap 的快速失败机制?
在使用迭代器对集合对象进行遍历的时候,如果 A 线程正在对集合进行遍历,此时 B 线程对集合进行修改(增加、删除、修改),或者 A 线程在遍历过程中对集合进行修改,都会导致 A 线程抛出 ConcurrentModificationException 异常。
HashTable?
HashTable 是较为远古的使用 Hash 算法的容器结构了,现在基本已被淘汰,单线程转为使用 HashMap,多线程使用 ConcurrentHashMap。
HashTable 也是一种 key-value 结构,它继承自 Dictionary<K,V>,同时实现了 Map<K,V> 和 Cloneable 以及 Serializable 接口。
其继承体系结构图如下图所示:
HashTable 的操作几乎和 HashMap 一致,主要的区别在于 HashTable 为了实现多线程安全,在几乎所有的方法上都加上了 synchronized 锁,而加锁就使得 HashTable 操作的效率十分低下。
HashMap 和 HashTable 区别?
(1)线程安全问题。
HashMap 线程不安全,HashTable 线程安全。
Hashtable 中的方法大多是 Synchronized 的,而 HashMap 中的方法在一般情况下是非 Synchronize 的。在多线程并发的环境下,可以直接使用 Hashtable,不需要自己为它的方法实现同步,但使用 HashMap 时就必须要自己增加同步处理。
HashTable 实现线程安全的代价就是效率变低,因为会锁住整个 HashTable,而 ConcurrentHashMap 做了相关优化,因为 ConcurrentHashMap 使用了分段锁,并不对整个数据进行锁定,效率比 HashTable 高很多。
(2)是否允许 null 值
HashMap 是允许 key 和 value 为 null 值的,HashMap 会把 null 的 key 转化为0进行存储。
HashTable 键值对都不能为空,否则会出现空指针异常。
(3)包含的 contains 方法不同
HashMap 是没有 contains()
方法的,而包括 containsValue()
和 containsKey()
方法;HashTable 则保留了 contains()
方法,效果同 containsValue()
,还包括 containsValue()
和 containsKey()
方法。
(4)计算 hash 值方式不同
HashMap 通过 hash()
方法重新计算了 key 的 hash 值,因为 hash 冲突变高,所以通过这种方法重算 hash 的值。hash()
方法源码如下:
static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
Hashtable 通过计算 key 的 hashCode()
对数组长度取模得到 hash 值就为最终 hash 值。
HashTable 的 put() 方法源码如下:
public synchronized V put(K key, V value) {if (value == null) {throw new NullPointerException();}Entry<?,?> tab[] = table;int hash = key.hashCode();int index = (hash & 0x7FFFFFFF) % tab.length;@SuppressWarnings("unchecked")Entry<K,V> entry = (Entry<K,V>)tab[index];for(; entry != null ; entry = entry.next) {if ((entry.hash == hash) && entry.key.equals(key)) {V old = entry.value;entry.value = value;return old;}}addEntry(hash, key, value, index);return null;
}
它们计算索引位置方法不同:
HashMap 在求 hash 值对应的位置索引时,index = (n - 1) & hash
。将哈希表的大小固定为了2的幂,因为是取模得到索引值,故这样取模时,不需要做除法,只需要做位运算。位运算比除法的效率要高很多。
HashTable 在求 hash 值位置索引时计算 index 的方法:
int index = (e.hash & 0x7FFFFFFF) % newCapacity;
&0x7FFFFFFF
的目的是为了将负的 hash 值转化为正值,因为 hash 值有可能为负数,而 &0x7FFFFFFF
后,只有符号位改变,而后面的位都不变。
(5)扩容方式不同
当容量不足时要进行 resize 方法,而 resize 的两个步骤:
- 扩容;
- rehash:
HashMap 和 HashTable 都会通过 rehash
重新计算 hash 值,但是计算 hash 值的方式不一样。
HashMap 哈希扩容必须要求为原容量的2倍,而且一定是2的幂次倍扩容结果,而且每次扩容时,原来数组中的元素依次重新计算存放位置,并重新插入;
而 Hashtable 扩容为原容量2倍加1;
(6)解决 hash 冲突方式不同
HashMap 在 jdk8 之前,如果出现 hash 冲突,则直接通过链表方式解决。jdk8 之后,如果出现 hash 冲突数量小于8,则是以链表方式解决冲突,而当冲突大于等于8时,就会将冲突的 Entry 转换为红黑树进行存储。当冲突数量小于6时,则又转化为链表存储。
在 HashTable 中, 都是以链表方式存储。
(7)初始容量
HashMap 初始容量16,HashTable 初始容量 11。
(8)遍历方式
HashMap 是 fail-fast;而 HashTable 不是。
TreeMap?
TreeMap 继承于 AbstractMap,实现了 Map、Cloneable、NavigableMap、Serializable 接口。TreeMap 的继承体系结构如下:
- TreeMap 继承了 AbstractMap,也是以 key-value 方式存储。实现了 NavigableMap 接口,可以支持一系列的导航方法。比如返回有序的 key 集合。
- TreeMap 是通过红黑树实现的。红黑树结构天然支持排序,根据其键的自然顺序进行排序,也可以根据创建 TreeMap 时提供的 Comparator 进行排序,具体取决于使用的构造方法。
- TreeMap 不允许出现重复的 key,当未实现 Comparator 接口时,key 不可以为null,当实现 Comparator 接口时,key 可以为 null,value 始终可以为 null。
- TreeMap 是非同步的。
HashMap 和 TreeMap 区别?
- HashMap 和 TreeMap 都不允许重复,都不是线程安全的。
- HashMap 可以允许一个 null key 和多个 null value。TreeMap 可以允许多个 null value,但是 key 需要根据是否实现 Comparator 接口来确定是否可以为 null。
- HashMap 的底层是数组+链表/红黑树,所以在添加,查找,删除等方法上面速度会非常快。而 TreeMap 的底层是一个 Tree 结构,所以速度会比较慢。
- HashMap 因为要保存一个 Array,所以会造成空间的浪费,而 TreeMap 只保存要保存的节点,所以占用的空间比较小。
- HashMap 如果出现 hash 冲突的话,效率会变差,不过在 java8 进行红黑树转换之后,效率有很大的提升。
- TreeMap 在添加和删除节点的时候会进行重排序,会对性能有所影响。
LinkedHashMap?
LinkedHashMap 继承结构如下:
LinkedHashMap 继承了 HashMap,是基于 HashMap 和双向链表来实现的。
LinkedHashMap 是有序的,可以分为插入顺序和访问顺序(通过 final boolean accessOrder 来实现访问顺序)。
LinkedHashMap 不允许重复,键和值都可以为 null。
LinkedHashMap 是线程不安全的。
ConcurrentHashMap?
ConcurrentHashMap 继承结构如下:
ConcurrentHashMap 是 HashMap 的升级版,HashMap 是线程不安全的,而 ConcurrentHashMap 是线程安全,他功能和实现原理和 HashMap 类似。
ConcurrentHashMap 底层实现
JDK1.7:
首先将数据分为一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据时,其他段的数据也能被其他线程访问。
ConcurrentHashMap 由 Segment 数组结构和 HashEntry 数组结构组成。Segment 实现了 ReentrantLock,所以 Segment 是一种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。
static class Segment<K,V> extends ReentrantLock implements Serializable {}
一个 ConcurrentHashMap 里包含一个 Segment 数组。Segment 的结构和 HashMap 类似,是一种数组和链表结构,一个 Segment 包含一个 HashEntry 数组,每个 HashEntry 是一个链表结构的元素,每个 Segment 守护着一个HashEntry数组里的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment的锁。
JDK1.8 中,ConcurrentHashMap 取消了 Segment 分段锁,采用 CAS 和 synchronized 来保证并发安全。数据结构跟 HashMap1.8 的结构类似,数组+链表/红黑二叉树。Java 8 在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为O(N))转换为红黑树(寻址时间复杂度为O(log(N))。synchronized 只锁定当前链表或红黑二叉树的首节点,这样只要 hash 不冲突,就不会产生并发,效率又提升N倍。
JDK7 和 JDK8 中 ConcurrentHashMap 的区别:
- JDK8 中新增了红黑树
- JDK7 中使用的是头插法,JDK8 中使用的是尾插法。
- JDK7 中使用了分段锁,而 JDK8 中没有使用分段锁。
- JDK7 中使用了 ReentrantLock,JDK8 中使用了 Synchronized。
- JDK7 中的扩容是每个 Segment 内部进行扩容,不会影响其他 Segment,而JDK8中的扩容和 HashMap 的扩容类似,只不过支持了多线程扩容,并且保证了线程安全。
ConcurrentHashMap 与 Hashtable 区别?
(1)底层数据结构:
JDK1.7 的 ConcurrentHashMap 底层采用分段的数组+链表实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构一样,数组+链表/红黑二叉树。
Hashtable 和 JDK1.8 之前的 HashMap 的底层数据结构类似都是采用数组+链表的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的。
(2)实现线程安全的方式:
在 JDK1.7 的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候已经摒弃了 Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用 synchronized 和 CAS 来操作。整个看起来就像是优化过且线程安全的 HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;
Hashtable 使用 synchronized 来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
两者的对比图:
JDK1.7 的 ConcurrentHashMap:
JDK1.8 的 ConcurrentHashMap(TreeBin:红黑二叉树节点,Node:链表节点):
(3)其他不同
- Hashtable 对 get/put/remove 都使用了同步操作,ConcurrentHashMap 只对 put/remove 同步。
- Hashtable 是快速失败的,遍历时改变结构会报错 ConcurrentModificationException。ConcurrentHashMap 是安全失败,允许并发检索和更新。
快速失败(fail-fast)和安全失败(fail-safe)的区别?
Iterator 的安全失败是基于对底层集合做拷贝,因此它不受源集合上修改的影响。java.util 包下面的所有的集合类都是快速失败的,而 java.util.concurrent 包下面的所有的类都是安全失败的。快速失败的迭代器会抛出 ConcurrentModificationException 异常,而安全失败的迭代器永远不会抛出这样的异常。
ConcurrentHashMap 的 CounterCell?
CounterCell 是 JDK8 中用来统计 ConcurrentHashMap 中所有元素个数的,在统计 ConcurentHashMap 时,不能直接对 ConcurrentHashMap 对象进行加锁然后再去统计,因为这样会影响 ConcurrentHashMap 的 put 等操作的效率。
在 JDK8 的实现中使用了 CounterCell+baseCount 来辅助进行统计,baseCount 是 ConcurrentHashMap 中的一个属性,某个线程在调用 ConcurrentHashMap 对象的 put 操作时,会先通过 CAS 去修改 baseCount 的值,如果 CAS 修改成功,就计数成功,如果 CAS 修改失败,则会从 CounterCell 数组中随机选出一个 CounterCell 对象,然后利用 CAS 去修改 CounterCell 对象中的值,因为存在 CounterCell 数组,所以当某个线程想要计数时,先尝试通过 CAS 去修改 baseCount 的值,如果没有修改成功,则从 CounterCell 数组中随机取出来一个 CounterCell 对象进行 CAS 计数,这样在计数时提高了效率。
所以 ConcurrentHashMap 在统计元素个数时,就是 baseCount 加上所有 CountCell 中的 value 值,所得的和就是所有的元素个数。
CurrentHashMap 扩容机制?
JDK1.7:
先对数组的长度增加一倍,然后遍历原来的旧的 table 数组,把每一个数组元素也就是 Node 链表迁移到新的数组里面,最后迁移完毕之后,把新数组的引用直接替换旧的。
JDK1.8:
扩容时候会判断这个值,如果超过阈值就要扩容,首先根据运算得到需要遍历的次数 i,然后利用 tabAt 方法获得 i 位置的元素 f,初始化一个 forwardNode 实例 fwd,如果 f == null,则在 table 中的 i 位置放入 fwd,否则采用头插法的方式把当前旧 table 数组的指定任务范围的数据给迁移到新的数组中,然后给旧 table 原位置赋值 fwd。直到遍历过所有的节点以后就完成了复制工作,把 table 指向 nextTable,并更新 sizeCtl 为新数组大小的 0.75 倍 ,扩容完成。在此期间如果其他线程的有读写操作都会判断 head 节点是否为 forwardNode 节点,如果是就帮助扩容。
comparable 和 Comparator 的区别?
- comparable 接口是出自 java.lang 包,它有一个 compareTo(Object obj) 方法用来排序,这个方法可以个给两个对象排序。具体来说,它返回负数,0,正数来表明输入对象小于,等于,大于已经存在的对象
- comparator 接口实际上是出自 java.util 包它有一个 compare(Object obj1, Object obj2) 方法用来排序,返回负数,0,正数表明第一个参数是小于,等于,大于第二个参数。
一般我们需要对一个集合使用自定义排序时,我们就要重写 compareTo() 方法或 compare() 方法,当需要对某一个集合实现两种排序方式,比如一个 song 对象中的歌名和歌手名分别采用一种排序方法的话,我们可以重写 compareTo() 方法和使用自制的 Comparator 方法或者以两个 Comparator 来实现歌名排序和歌星名排序,第二种代表我们只能使用两个参数版的 Collections.sort().
Comparator 定制排序:
ArrayList<Integer> arrayList = new ArrayList<Integer>();
arrayList.add(-1);
arrayList.add(3);
arrayList.add(3);
arrayList.add(-5);
arrayList.add(7);
arrayList.add(4);
arrayList.add(-9);
arrayList.add(-7);
System.out.println("原始数组:");
System.out.println(arrayList);
// void reverse(List list):反转
Collections.reverse(arrayList);
System.out.println("Collections.reverse(arrayList):");
System.out.println(arrayList);// void sort(List list),按自然排序的升序排序
Collections.sort(arrayList);
System.out.println("Collections.sort(arrayList):");
System.out.println(arrayList);
// 定制排序的用法
Collections.sort(arrayList, new Comparator<Integer>() {@Overridepublic int compare(Integer o1, Integer o2) {return o2.compareTo(o1);}
});
System.out.println("定制排序后:");
System.out.println(arrayList);// 输出
原始数组:
[-1, 3, 3, -5, 7, 4, -9, -7]
Collections.reverse(arrayList):
[-7, -9, 4, 7, -5, 3, 3, -1]
Collections.sort(arrayList):
[-9, -7, -5, -1, 3, 3, 4, 7]
定制排序后:
[7, 4, 3, 3, -1, -5, -7, -9]
Java 多线程
线程和进程的区别?
- 进程一个在内存中运行的应用程序,每个进程都有自己独立的一块内存空间,一个进程可以有多个线程。
- 进程和线程的主要差别在于它们是不同的操作系统资源管理方式。进程有独立的地址空间,一个进程崩溃后,在保护模式下不会对其它进程产生影响,而线程只是一个进程中的不同执行路径。
- 线程有自己的堆栈和局部变量,但线程之间没有单独的地址空间,一个线程死掉就等于整个进程死掉,所以多进程的程序要比多线程的程序健壮,但在进程切换时,耗费资源较大,效率要差一些。
- 但对于一些要求同时进行并且又要共享某些变量的并发操作,只能用线程,不能用进程。
线程调度算法?
抢占式。一个线程用完CPU之后,操作系统会根据线程优先级、线程饥饿情况等数据算出一个总的优先级并分配下一个时间片给某个线程执行。
线程调度器和时间分片?
- 线程调度器(Thread Scheduler)是一个操作系统服务,它负责为 Runnable 状态的线程分配 CPU 时间。一旦我们创建一个线程并启动它,它的执行便依赖于线程调度器的实现。
- 时间分片(Time Slicing)是指将可用的 CPU 时间分配给可用的 Runnable 线程的过程。分配 CPU 时间可以基于线程优先级或者线程等待的时间。
- 线程调度并不受到 Java 虚拟机控制,所以由应用程序来控制它是更好的选择,所以不要让你的程序依赖于线程的优先级。
线程数过多有什么异常?
- 线程的生命周期开销非常高
- 消耗过多的CPU资源:如果可运行的线程数量多于可用处理器的数量,那么有线程将会被闲置。大量空闲的线程会占用许多内存,给垃圾回收器带来压力,而且大量的线程在竞争 CPU 资源时还将产生其他性能的开销。
- 降低稳定性:JVM 在可创建线程的数量上存在一个限制,这个限制值将随着平台的不同而不同,并且承受着多个因素制约,包括 JVM 的启动参数、Thread 构造函数中请求栈的大小,以及底层操作系统对线程的限制等。如果破坏了这些限制,那么可能抛出 OutOfMemoryError 异常。
创建线程的方式?
- 继承 Thread 类创建线程类
- 通过 Runnable 接口创建线程类
- 通过 Callable 和 Future 创建线程
- 通过线程池创建
几种创建方式的区别:
(1)实现 Runnable、Callable 接口的方式
优势:
- 线程类只是实现了 Runnable 接口或 Callable 接口,还可以继承其他类。
- 在这种方式下,多个线程可以共享同一个 target 对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。
劣势:
- 编程稍微复杂,如果要访问当前线程,则必须使用 Thread.currentThread() 方法。
(2)继承 Thread 类的方式
优势是:
- 编写简单,如果需要访问当前线程,则无需使用 Thread.currentThread() 方法,直接使用 this 即可获得当前线程。
劣势是:
- 线程类已经继承了 Thread 类,所以不能再继承其他父类。
(3)Runnable 和 Callable 的区别
- Callable 规定(重写)的方法是 call(),Runnable 规定(重写)的方法是 run()。
- Callable 的任务执行后可返回值,而 Runnable 的任务是不能返回值的。
- call() 方法可以抛出异常,run() 方法不可以。
- 运行 Callable 任务可以拿到一个 Future 对象,表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。通过 Future 对象可以了解任务执行情况,可取消任务的执行,还可获取执行结果。
线程的生命周期?
(1)新建状态(New)
当线程对象对创建后,即进入了新建状态,如:Thread t = new MyThread();
(2)就绪状态(Runnable)
当调用线程对象的 start() 方法,线程即进入就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,随时等待CPU调度执行,并不是说执行了 start() 此线程立即就会执行;
(3)运行状态(Running)
当 CPU 开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态。注:就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中;
(4)阻塞状态(Blocked)
处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才有机会再次被 CPU 调用以进入到运行状态。
根据阻塞产生的原因不同,阻塞状态又可以分为三种:
- 等待阻塞:运行状态中的线程执行 wait() 方法,使本线程进入到等待阻塞状态;
- 同步阻塞:线程在获取 synchronized 同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态;
- I/O阻塞:通过调用线程的 sleep() 或 join() 或发出了 I/O 请求时,线程会进入到阻塞状态。当sleep() 状态超时、join() 等待线程终止或者超时、或者 I/O 处理完毕时,线程重新转入就绪状态。
(5)死亡状态(Dead):线程执行完了或者因异常退出了 run() 方法,该线程结束生命周期。
start() 和 run() 区别?
- 只有调用了 start() 方法,才会表现出多线程的特性,不同线程的 run() 方法里面的代码交替执行。
- 如果只是调用 run() 方法,那么代码还是同步执行的,必须等待一个线程的 run() 方法里面的代码全部执行完毕之后,另外一个线程才可以执行其 run() 方法里面的代码。
sleep() 和 wait() 区别?
- sleep 就是正在执行的线程主动让出 cpu,cpu 去执行其他线程,在 sleep 指定的时间过后,cpu 才会回到这个线程上继续往下执行,如果当前线程进入了同步锁,sleep 方法并不会释放锁,即使当前线程使用 sleep 方法让出了 cpu,但其他被同步锁挡住了的线程也无法得到执行。
- wait 是指在一个已经进入了同步锁的线程内,让自己暂时让出同步锁,以便其他正在等待此锁的线程可以得到同步锁并运行,只有其他线程调用了notify 方法,调用 wait 方法的线程就会解除 wait 状态和程序可以再次得到锁后继续向下运行。notify 并不释放锁,只是告诉调用过 wait 方法的线程可以去参与获得锁的竞争了,但不是马上得到锁,因为锁还在别人手里,别人还没释放。如果 notify 方法后面的代码还有很多,需要这些代码执行完后才会释放锁。
notify 和 notifyAll 区别?
notify() 方法不能唤醒某个具体的线程,所以只有一个线程在等待的时候它才有用武之地。
notifyAll() 唤醒所有线程并允许他们争夺锁确保了至少有一个线程能继续运行。
为什么 wait/notify/notifyAll 这些方法不在 thread 类里面?
JAVA提供的锁是对象级的而不是线程级的,每个对象都有锁,通过线程获得,如果线程需要等待某些锁那么调用对象中的 wait() 方法就有意义了。
如果 wait() 方法定义在 Thread 类中,线程正在等待的是哪个锁就不明显了。
简单的说,由于 wait,notify 和 notifyAll 都是锁级别的操作,所以把他们定义在 Object 类中因为锁属于对象。
为什么 wait 和 notify 方法要在同步块中调用?
主要是因为 JavaAPI 强制要求这样做,如果你不这么做,你的代码会抛出 IllegalMonitorStateException 异常。
还有一个原因是为了避免 wait 和 notify 之间产生竞态条件。
stop() 和 suspend() 方法?
(1)stop() 方法
反对使用 stop(),是因为它不安全。它会解除由线程获取的所有锁定,而且如果对象处于一种不连贯状态,那么其他线程能在那种状态下检查和修改它们,结果很难检查出真正的问题所在。
(2)suspend() 方法
suspend() 方法容易发生死锁。调用 suspend() 的时候,目标线程会停下来,但却仍然持有在这之前获得的锁定。此时其他任何线程都不能访问锁定的资源,除非被"挂起" 的线程恢复运行。对任何线程来说,如果它们想恢复目标线程,同时又试图使用任何一个锁定的资源,就会造成死锁。
所以不应该使用 suspend(),而应在自己的 Thread 类中置入一个标志,指出线程应该活动还是挂起。若标志指出线程应该挂起,便用 wait() 命其进入等待状态。若标志指出线程应当恢复,则用一个 notify() 重新启动线程。
Thread.sleep(0) 的作用?
由于 Java 采用抢占式的线程调度算法,因此可能会出现某条线程常常获取到 CPU 控制权的情况,为了让某些优先级比较低的线程也能获取到 CPU 控制权,可以使用 Thread.sleep(0) 手动触发一次操作系统分配时间片的操作,这也是平衡 CPU 控制权的一种操作。
什么是守护线程?
与守护线程相对应的就是用户线程,守护线程就是守护用户线程,当用户线程全部执行完结束之后,守护线程才会跟着结束。也就是守护线程必须伴随着用户线程,如果一个应用内只存在一个守护线程,没有用户线程,守护线程自然会退出。
yield() 方法?
yield() 方法可以暂停当前正在执行的线程对象,让其它有相同优先级的线程执行。
它是一个静态方法而且只保证当前线程放弃 CPU 占用而不能保证使其它线程一定能占用 CPU,执行 yield() 的线程有可能在进入到暂停状态后马上又被执行。
线程运行时发生异常会怎样?
如果异常没有被捕获该线程将会停止执行,Thread.UncaughtExceptionHandler 是用于处理未捕获异常造成线程突然中断情况的一个内嵌接口。
当一个未捕获异常将造成线程中断的时候 JVM 会使用 Thread.getUncaughtExceptionHandler() 来查询线程的 UncaughtExceptionHandler 并将线程和异常作为参数传递给 handler 的 uncaughtException() 方法进行处理。
什么是多线程上下文切换?
多线程的上下文切换是指 CPU 控制权由一个已经正在运行的线程切换到另外一个就绪并等待获取 CPU 执行权的线程的过程。
多线程中的忙循环是什么?
- 忙循环就是程序员用循环让一个线程等待,不像传统方法 wait(),sleep() 或 yield() 它们都放弃了CPU 控制,而忙循环不会放弃 CPU,它就是在运行一个空循环。这么做的目的是为了保留 CPU 缓存。
- 在多核系统中,一个等待线程醒来的时候可能会在另一个内核运行,这样会重建缓存。为了避免重建缓存和减少等待重建的时间就可以使用忙循环。
- 处于等待状态的线程可能会收到错误警报和伪唤醒,如果不在循环中检查等待条件,程序就会在没有满足结束条件的情况下退出。
- 因此,当一个等待线程醒来时,不能认为它原来的等待状态仍然是有效的,在 notify() 方法调用之后和等待线程醒来之前这段时间它可能会改变。
并发编程三要素?
- 原子性:原子性是指一个线程的操作是不能被其他线程打断,同一时间只有一个线程对一个变量进行操作。在多线程情况下,每个线程的执行结果不受其他线程的干扰,比如说多个线程同时对同一个共享成员变量n++100次,如果n初始值为0,n最后的值应该是100,所以说它们是互不干扰的,这就是传说的中的原子性。但n++并不是原子性的操作,要使用AtomicInteger保证原子性。
- 可见性:可见性是指某个线程修改了某一个共享变量的值,而其他线程是否可以看见该共享变量修改后的值。在单线程中肯定不会有这种问题,单线程读到的肯定都是最新的值,而在多线程编程中就不一定了。每个线程都有自己的工作内存,线程先把共享变量的值从主内存读到工作内存,形成一个副本,当计算完后再把副本的值刷回主内存,从读取到最后刷回主内存这是一个过程,当还没刷回主内存的时候这时候对其他线程是不可见的,所以其他线程从主内存读到的值是修改之前的旧值。像CPU的缓存优化、硬件优化、指令重排及对JVM编译器的优化,都会出现可见性的问题。
- 有序性:我们都知道程序是按代码顺序执行的,对于单线程来说确实是如此,但在多线程情况下就不是如此了。为了优化程序执行和提高CPU的处理性能,JVM和操作系统都会对指令进行重排,也就说前面的代码并不一定都会在后面的代码前面执行,即后面的代码可能会插到前面的代码之前执行,只要不影响当前线程的执行结果。所以,指令重排只会保证当前线程执行结果一致,但指令重排后势必会影响多线程的执行结果。虽然重排序优化了性能,但也是会遵守一些规则的,并不能随便乱排序,只是重排序会影响多线程执行的结果。
线程同步需要注意什么?
- 尽量缩小同步的范围,增加系统吞吐量。
- 分布式同步锁无意义,要使用分布式锁。
- 防止死锁,注意加锁顺序。
如何保证线程按顺序执行?
(1)使用 Thread 原生方法 join
使用 Thread 原生方法 join,join 方法是使所属的线程对象x正常执行 run() 方法中的任务,而当前线程进行无限的阻塞,等到线程x执行完成后再继续执行当前线程后面的代码。
public static void main(String[] args) {final Thread T1 = new Thread(new Runnable() {public void run() {System.out.println("T1 run");}});final Thread T2 = new Thread(new Runnable() {public void run() {System.out.println("T2 run");try{T1.join();}catch (Exception e){e.printStackTrace();}System.out.println("T2 run end");}});Thread T3 = new Thread(new Runnable() {public void run() {System.out.println("T3 run");try{T2.join();}catch (Exception e){e.printStackTrace();}System.out.println("T3 run end");}});T1.start();T2.start();T3.start();}
(2)使用线程间通信的等待/通知机制
wait() 方法是 Object 类的方法,该方法用来将当前线程置入“预执行队列”中,并且在 wait() 所在的代码行处停止执行,直到接到通知或被中断为止。
在调用 wait() 之前,线程必须获取到该对象的对象级别锁,即只能在同步方法或同步块中调用 wait() 方法,在执行 wait() 方法后,当前线程释放锁。
private static boolean T2Run = false; //标识位,用来通知T2线程执行
private static boolean T3Run = false;public static void main(String[] args) {Object lock1 = new Object();Object lock2 = new Object();Thread T1 = new Thread(new Runnable() {@Overridepublic void run() {synchronized (lock1){System.out.println("T1 run");//t1 线程通知t2执行T2Run = true;lock1.notify();}}});Thread T2 = new Thread(new Runnable() {@Overridepublic void run() {synchronized (lock1){if(!T2Run){System.out.println("T2 wait");try {lock1.wait();} catch (Exception e){e.printStackTrace();}}System.out.println("T2 run");//t2 线程通知t3执行synchronized (lock2){T3Run = true;lock2.notify();}}}});Thread T3 = new Thread(new Runnable() {@Overridepublic void run() {synchronized (lock2){if (!T3Run){System.out.println("T3 wait");try {lock2.wait();} catch (Exception e){e.printStackTrace();}}System.out.println("T3 run");}}});T1.start();T2.start();T3.start();
}
(3)使用 Conditon
关键字 synchronized 与 wait 和 notify/notifyAll 方法相结合可以实现等待/通知模式,类 ReetrantLock 也可以实现同样的功能,但需要借助于 Condition 对象。
Condition 可以实现多路通知,也就是在一个 Lock 对象里面可以创建多个 Condition(即对象监视器)实例,线程对象可以注册在指定的 Condition 中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。在使用 notify/notifyAll 通知时,被通知的线程却是由 JVM 随机选择的。
/*** 使用condition*/
private Lock lock = new ReentrantLock();private Condition condition2 = lock.newCondition();
private Condition condition3 = lock.newCondition();private static Boolean t2Run = false;
private static Boolean t3Run = false;private void useCondition(){Thread T1 = new Thread(new Runnable() {@Overridepublic void run() {lock.lock(); //获取锁System.out.println("T1 run");t2Run = true; //设置t2可以运行System.out.println("T1 run finish signal T2");condition2.signal(); //通知T2执行lock.unlock(); //解锁当前线程System.out.println("T1 unlock");}});Thread T2 = new Thread(new Runnable() {@Overridepublic void run() {lock.lock();try{if (!t2Run){condition2.await(); //如果是false ,则等待}//若是true,则代表T2可以执行System.out.println("T2 run");t3Run = true;condition3.signal();System.out.println("T2 run finish signal T3");}catch (Exception e){e.printStackTrace();} finally {lock.unlock();}}});Thread T3 = new Thread(new Runnable() {@Overridepublic void run() {lock.lock();try{if (!t3Run){condition3.await(); //如果是false ,则等待}//若是true,则代表T2可以执行System.out.println("T3 run");}catch (Exception e){e.printStackTrace();} finally {lock.unlock();}}});T1.start();T2.start();T3.start();
}
(4)使用线程池
使用 newSingleThreadExecutor 线程池,由于核心线程数只有一个,所以能够顺序执行。
/*** 线程池* 核心线程数:1* 最大线程数:1* 在日常中不建议使用 newSingleThreadExecutor,因为阻塞队列个数没有限制,会导致内存溢出**/
static ExecutorService executorService = Executors.newSingleThreadExecutor();public static void main(String[] args) {Thread T1 = new Thread(new Runnable() {@Overridepublic void run() {System.out.println("T1 run");}});Thread T2 = new Thread(new Runnable() {@Overridepublic void run() {System.out.println("T2 run");}});Thread T3 = new Thread(new Runnable() {@Overridepublic void run() {System.out.println("T3 run");}});executorService.submit(T1);executorService.submit(T2);executorService.submit(T3);executorService.shutdown();
}
(5)使用线程的 CountDownLatch
CountDownLatch 的作用是:当一个线程需要另外一个或多个线程完成后,再开始执行。比如主线程要等待一个子线程完成环境相关配置的加载工作,主线程才继续执行,就可以利用 CountDownLatch 来实现。
比较重要的方法:
- CountDownLatch(int count):构造方法,创建一个值为count 的计数器
- await():阻塞当前线程,将当前线程加入阻塞队列。
- countDown():对计数器进行递减1操作,当计数器递减至0时,当前线程会去唤醒阻塞队列里的所有线程。
/*** 计数器1 用于T1线程通知T2线程* 计数器2 用于T2线程通知T3线程* 注意:这里个数都设置成立1 ,当T1执行完成后,执行countDown,来通知T2线程*/
static CountDownLatch countDownLatch1 = new CountDownLatch(1);
static CountDownLatch countDownLatch2 = new CountDownLatch(1);public static void main(String[] args) {Thread T1 = new Thread(new Runnable() {@Overridepublic void run() {System.out.println("T1 run");countDownLatch1.countDown();System.out.println("T1 countDown finish");}});Thread T2 = new Thread(new Runnable() {@Overridepublic void run() {try{countDownLatch1.await();}catch (Exception e){e.printStackTrace();}System.out.println("T2 run");countDownLatch2.countDown();}});Thread T3 = new Thread(new Runnable() {@Overridepublic void run() {try{countDownLatch2.await();}catch (Exception e){e.printStackTrace();}System.out.println("T3 run");}});
}
(6)使用 cyclicbarrier
多个线程互相等待,直到到达同一个同步点,再继续一起执行。
比较重要的方法:
- CyclicBarrier(int parties):构造方法,参数表示拦截的线程的个数
- CyclicBarrier(int parties, Runnable barrierAction):也是构造方法,可以通过后面的参数,这是线程的优先级
- await():告诉 CyclicBarrier 自己已经到达同步点,然后当前线程被阻塞,当所有线程都到达同步点(barrier)时,唤醒所有的等待线程,一起往下继续运行,可根据参数 barrierAction 决定优先执行的线程
/*** 设置2个线程互相等待,直到到达同一个同步点,再继续一起执行。T1不执行完,T2就永远不会执行*/
static CyclicBarrier cyclicBarrier1 = new CyclicBarrier(2);
static CyclicBarrier cyclicBarrier2 = new CyclicBarrier(2);public static void main(String[] args) {Thread T1 = new Thread(new Runnable() {@Overridepublic void run() {System.out.println("T1 run");try{cyclicBarrier1.await();}catch (Exception e){e.printStackTrace();}}});Thread T2 = new Thread(new Runnable() {@Overridepublic void run() {try{cyclicBarrier1.await();}catch (Exception e){e.printStackTrace();}System.out.println("T2 run");try{cyclicBarrier2.await();}catch (Exception e){e.printStackTrace();}}});Thread T3 = new Thread(new Runnable() {@Overridepublic void run() {try{cyclicBarrier2.await();}catch (Exception e){e.printStackTrace();}System.out.println("T3 run");}});T1.start();;T2.start();T3.start();
}
(7)使用信号量 Semaphore
Semaphore 计数信号量,常用于限制可以访问某些资源(物理或逻辑的)线程数目。
常用的方法:
- Semaphore(int permits):构造方法,permits 就是允许同时运行的线程数目。
- public Semaphore(int permits,boolean fair):permits 就是允许同时运行的线程数目,fair 是否为公平锁,如果是公平锁,那么获得锁的顺序与线程启动顺序有关。
- void acquire():从此信号量获取一个许可,在提供一个许可前一直将线程阻塞,否则线程被中断。
- tryAcquire():尝试获得令牌,返回获取令牌成功或失败,不阻塞线程。
- release():释放一个令牌,唤醒一个获取令牌不成功的阻塞线程。
/*** 设置信号量初始值为0 ,让T1 把信号量+1,这样,T2就可以执行了*/
static Semaphore semaphore1 = new Semaphore(0);
static Semaphore semaphore2 = new Semaphore(0);public static void main(String[] args) {Thread T1 = new Thread(new Runnable() {@Overridepublic void run() {System.out.println("T1 run");try{semaphore1.release();}catch (Exception e){e.printStackTrace();}System.out.println("T1 semaphore1 + 1");}});Thread T2 = new Thread(new Runnable() {@Overridepublic void run() {try{semaphore1.acquire();}catch (Exception e){e.printStackTrace();}System.out.println("T2 run");try{semaphore2.release();}catch (Exception e){e.printStackTrace();}System.out.println("T2 semaphore2 + 1");}});Thread T3 = new Thread(new Runnable() {@Overridepublic void run() {try{semaphore2.acquire();}catch (Exception e){e.printStackTrace();}System.out.println("T3 run");}});T1.start();;T2.start();T3.start();
}
实现可见性的方法?
synchronized 或者 Lock:保证同一个时刻只有一个线程获取锁执行代码,锁释放之前把最新的值刷新到主内存,实现可见性。
Future?
在并发编程中,我们经常用到非阻塞的模型,在之前的多线程的三种实现中,不管是继承thread类还是实现runnable接口,都无法保证获取到之前的执行结果。通过实现Callback接口,并用Future可以来接收多线程的执行结果。
Future表示一个可能还没有完成的异步任务的结果,针对这个结果可以添加Callback以便在任务执行成功或失败后作出相应的操作。
FutureTask?
FutureTask表示一个异步运算的任务。FutureTask里面可以传入一个Callable的具体实现类,可以对这个异步运算的任务的结果进行等待获取、判断是否已经完成、取消任务等操作。当然,由于FutureTask也是Runnable接口的实现类,所以FutureTask也可以放入线程池中。
常用的并发工具类?
- CountDownLatch
- CyclicBarrier
- Semaphore
- Exchanger
什么是 ThreadLocal?
- ThreadLocal 是线程本地存储,在每个线程中都创建了一个 ThreadLocalMap 对象,每个线程可以访问自己内部 ThreadLocalMap 对象内的 value。
- 经典的使用场景是为每个线程分配一个 JDBC 连接 Connection。这样就可以保证每个线程的都在各自的 Connection 上进行数据库的操作,不会出现 A 线程关了 B线程正在使用的 Connection; 还有 Session 管理 等问题。
- 每个线程都会拥有他们自己的Thread 变量,它们可以使用 get()、set() 方法去获取他们的默认值或者在线程内部改变他们的值。ThreadLocal 实例通常是希望它们同线程状态关联起来是 private static 属性。
为什么要用ThreadLocal?
并发编程是一项非常重要的技术,它让我们的程序变得更加高效。但在并发的场景中,如果有多个线程同时修改公共变量,可能会出现线程安全问题,即该变量最终结果可能出现异常。
为了解决线程安全问题,JDK 出现了很多技术手段,比如:使用 synchronized 或 Lock,给访问公共资源的代码上锁,保证了代码的原子性。但在高并发的场景中,如果多个线程同时竞争一把锁,这时会存在大量的锁等待,可能会浪费很多时间,让系统的响应时间一下子变慢。
因此,JDK 还提供了另外一种用空间换时间的新思路:ThreadLocal。它的核心思想是:共享变量在每个线程都有一个副本,每个线程操作的都是自己的副本,对另外的线程没有影响。
@Service
public class ThreadLocalService {private static final ThreadLocal<Integer> threadLocal = new ThreadLocal<>();public void add() {threadLocal.set(1);doSamething();Integer integer = threadLocal.get();}
}
ThreadLocal的原理?
ThreadLocal 的内部有一个静态的内部类叫:ThreadLocalMap
public class ThreadLocal<T> {...public T get() {//获取当前线程Thread t = Thread.currentThread();//获取当前线程的成员变量ThreadLocalMap对象ThreadLocalMap map = getMap(t);if (map != null) {//根据threadLocal对象从map中获取Entry对象ThreadLocalMap.Entry e = map.getEntry(this);if (e != null) {@SuppressWarnings("unchecked")//获取保存的数据T result = (T)e.value;return result;}}//初始化数据return setInitialValue();}private T setInitialValue() {//获取要初始化的数据T value = initialValue();//获取当前线程Thread t = Thread.currentThread();//获取当前线程的成员变量ThreadLocalMap对象ThreadLocalMap map = getMap(t);//如果map不为空if (map != null)//将初始值设置到map中,key是this,即threadLocal对象,value是初始值map.set(this, value);else//如果map为空,则需要创建新的map对象createMap(t, value);return value;}public void set(T value) {//获取当前线程Thread t = Thread.currentThread();//获取当前线程的成员变量ThreadLocalMap对象ThreadLocalMap map = getMap(t);//如果map不为空if (map != null)//将值设置到map中,key是this,即threadLocal对象,value是传入的value值map.set(this, value);else//如果map为空,则需要创建新的map对象createMap(t, value);}static class ThreadLocalMap {...}...
}
ThreadLocal 的 get 方法、set 方法和 setInitialValue 方法,其实最终操作的都是 ThreadLocalMap 类中的数据。
其中 ThreadLocalMap 类的内部如下:
static class ThreadLocalMap {static class Entry extends WeakReference<ThreadLocal<?>> {Object value;Entry(ThreadLocal<?> k, Object v) {super(k);value = v;}}...private Entry[] table;...
}
ThreadLocalMap 里面包含一个静态的内部类 Entry,该类继承于 WeakReference 类,说明 Entry 是一个弱引用。
ThreadLocalMap 内部还包含了一个 Entry 数组,其中:Entry = ThreadLocal + value。
而 ThreadLocalMap被定义成了 Thread 类的成员变量。
public class Thread implements Runnable {...ThreadLocal.ThreadLocalMap threadLocals = null;
}
下面用一张图从宏观上,认识一下ThreadLocal的整体结构:
从上图中看出,在每个 Thread 类中,都有一个 ThreadLocalMap 的成员变量,该变量包含了一个 Entry 数组,该数组真正保存了 ThreadLocal 类 set 的数据。
Entry 是由 threadLocal 和 value 组成,其中 threadLocal 对象是弱引用,在GC的时候,会被自动回收。而 value 就是 ThreadLocal 类 set 的数据。
下面用一张图总结一下引用关系:
上图中除了 Entry 的 key 对 ThreadLocal 对象是弱引用,其他的引用都是强引用。
需要特别说明的是,上图中 ThreadLocal 对象我画到了堆上,其实在实际的业务场景中不一定在堆上。因为如果 ThreadLocal 被定义成了 static 的,ThreadLocal 的对象是类共用的,可能出现在方法区。
为什么用 ThreadLocal 做key?
ThreadLocalMap 为什么要用 ThreadLocal 做key,而不是用 Thread 做 key?如果在你的应用中,一个线程中只使用了一个 ThreadLocal 对象,那么使用 Thread 做 key也未尝不可。
@Service
public class ThreadLocalService {private static final ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
}
但实际情况中,你的应用,一个线程中很有可能不只使用了一个 ThreadLocal 对象。这时使用 Thread 做 key不就出有问题?
@Service
public class ThreadLocalService {private static final ThreadLocal<Integer> threadLocal1 = new ThreadLocal<>();private static final ThreadLocal<Integer> threadLocal2 = new ThreadLocal<>();private static final ThreadLocal<Integer> threadLocal3 = new ThreadLocal<>();
}
假如使用 Thread 做 key 时,你的代码中定义了3个 ThreadLocal 对象,那么,通过 Thread 对象,它怎么知道要获取哪个 ThreadLocal 对象呢?
如下图所示:
因此,不能使用 Thread 做 key,而应该改成用 ThreadLocal 对象做 key,这样才能通过具体 ThreadLocal 对象的 get 方法,轻松获取到你想要的 ThreadLocal 对象。
如下图所示:
ThreadLocal 如何解决并发安全?
- ThreadLocal 这是 Java 提供的一种保存线程私有信息的机制,因为其在整个线程生命周期内有效,所以可以方便地在一个线程关联的不同业务模块之间传递信息,比如事务 ID、Cookie等上下文相关信息。
- ThreadLocal 为每一个线程维护变量的副本,把共享数据的可见范围限制在同一个线程之内,其实现原理是,在 ThreadLocal 类中有一个Map,用于存储每一个线程的变量的副本。
使用 ThreadLocal 需要注意些什么?
- 使用 ThreadLocal 要注意 remove!
- ThreadLocal的实现是基于一个所谓的 ThreadLocalMap,在 ThreadLocalMap 中,它的 key 是一个弱引用。
- 通常弱引用都会和引用队列配合清理机制使用,但是 ThreadLocal 是个例外,它并没有这么做。
- 这意味着,废弃项目的回收依赖于显式地触发,否则就要等待线程结束,进而回收相应 ThreadLocalMap!这就是很多 OOM 的来源,所以通常都会建议,应用一定要自己负责 remove,并且不要和线程池配合,因为 worker 线程往往是不会退出的。
线程池?
线程作为操作系统宝贵的资源,对它的使用需要进行控制管理,线程池(ThreadPool)是一种基于池化思想管理(类似连接池、常量池、对象池等)和使用线程的机制。它是将多个线程预先存储在一个“池子"内,当有任务出现时可以避免重新创建和销毁线程所带来性能开销,只需要从“池子"内取出相应的线程执行对应的任务即可。
JUC 给我们提供了 ThreadPoolExecutor 体系类来帮助我们更方便的管理线程、并行执行任务。
池化思想在计算机的应用也比较广泛,比如以下这些:
- 内存池(Memory Pooling):预先申请内存,提升申请内存速度,减少内存碎片。
- 连接池(Connection Pooling):预先申请数据库连接,提升申请连接的速度,降低系统的开销。
- 实例池(Object Pooling):循环使用对象,减少资源在初始化和释放时的昂贵损耗。
线程池的优点:
- 降低资源消耗。降低频繁创建、销毁线程带来的额外开销,复用已创建线程。
- 降低使用复杂度。将任务的提交和执行进行解耦,我们只需要创建一个线程池,然后往里面提交任务就行,具体执行流程由线程池自己管理,降低使用复杂度。
- 提高线程可管理性。能安全有效的管理线程资源,避免不加限制无限申请造成资源耗尽风险。
- 提高响应速度。任务到达后,直接复用已创建好的线程执行。
线程池的使用场景:
- 快速响应用户请求,响应速度优先。比如一个用户请求,需要通过 RPC 调用好几个服务去获取数据然后聚合返回,此场景就可以用线程池并行调用,响应时间取决于响应最慢的那个 RPC 接口的耗时;又或者一个注册请求,注册完之后要发送短信、邮件通知,为了快速返回给用户,可以将该通知操作丢到线程池里异步去执行,然后直接返回客户端成功,提高用户体验。
- 单位时间处理更多请求,吞吐量优先。比如接受 MQ 消息,然后去调用第三方接口查询数据,此场景并不追求快速响应,主要利用有限的资源在单位时间内尽可能多的处理任务,可以利用队列进行任务的缓冲。
线程池创建方式?
线程池的创建可以用 Executors 类来完成,返回值是 ExecutorService 接口,ExecutorService 是 Java 提供的用于管理线程池的接口,该接口的两个作用:控制线程数量和重用线程。
线程池创建方式有如下几种:
线程池创建方式 | 描述 |
newFixedThreadPool() | 创建一个可重用固定个数的线程池,以共享的无界队列方式来运行这些线程。 |
newSingleThreadExecutor() | 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行,适用于有顺序的任务的应用场景。 |
newCachedThreadPool() | 可缓存的线程池,该线程池中没有核心线程,非核心线程的数量为 |
newScheduledThreadPool(int corePoolSize) | 创建一个指定大小的、支持定时及周期性任务执行的线程池。 |
newSingleThreadScheduledExecutor() | 创建一个单线程的可以支持定时及周期性任务执行的线程池。 |
newWorkStealingPool() | 创建一个抢占式执行的线程池(任务执行顺序不确定)【JDK 1.8 添加】。 |
Executors 类 | 一般不推荐使用 Executors 类创建线程池,而推荐采用手动方式通过 ThreadPoolExecutor 创建线程池。 |
ThreadPoolExecutor |
使用 newFixedThreadPool():
public static ExecutorService newFixedThreadPool(int nThreads) {return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>());
}public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {return new ThreadPoolExecutor(nThreads, nThreads,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>(),threadFactory);
}
使用 newSingleThreadExecutor():
public static ExecutorService newSingleThreadExecutor() {return new FinalizableDelegatedExecutorService(new ThreadPoolExecutor(1, 1,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>()));
}public static ExecutorService newSingleThreadExecutor(ThreadFactory threadFactory) {return new FinalizableDelegatedExecutorService(new ThreadPoolExecutor(1, 1,0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>(),threadFactory));
}
使用 newCachedThreadPool():
public static ExecutorService newCachedThreadPool() {return new ThreadPoolExecutor(0, Integer.MAX_VALUE,60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>());
}public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {return new ThreadPoolExecutor(0, Integer.MAX_VALUE,60L, TimeUnit.SECONDS,new SynchronousQueue<Runnable>(),threadFactory);
}
使用 newScheduledThreadPool(int corePoolSize):
public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {return new ScheduledThreadPoolExecutor(corePoolSize);
}public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize, ThreadFactory threadFactory) {return new ScheduledThreadPoolExecutor(corePoolSize, threadFactory);
}
使用 newSingleThreadScheduledExecutor():
public static ScheduledExecutorService newSingleThreadScheduledExecutor() {return new DelegatedScheduledExecutorService(new ScheduledThreadPoolExecutor(1));
}public static ScheduledExecutorService newSingleThreadScheduledExecutor(ThreadFactory threadFactory) {return new DelegatedScheduledExecutorService(new ScheduledThreadPoolExecutor(1, threadFactory));
}
使用 newWorkStealingPool():
public static ExecutorService newWorkStealingPool() {return new ForkJoinPool(Runtime.getRuntime().availableProcessors(),ForkJoinPool.defaultForkJoinWorkerThreadFactory,null, true);
}public static ExecutorService newWorkStealingPool(int parallelism) {return new ForkJoinPool(parallelism,ForkJoinPool.defaultForkJoinWorkerThreadFactory,null, true);
}
使用 ThreadPoolExecutor,ThreadPoolExecutor 全参构造源码如下:
public ThreadPoolExecutor(int corePoolSize,int maximumPoolSize,long keepAliveTime,TimeUnit unit,BlockingQueue<Runnable> workQueue,ThreadFactory threadFactory,RejectedExecutionHandler handler) {if (corePoolSize < 0 ||maximumPoolSize <= 0 ||maximumPoolSize < corePoolSize ||keepAliveTime < 0)throw new IllegalArgumentException();if (workQueue == null || threadFactory == null || handler == null)throw new NullPointerException();this.acc = System.getSecurityManager() == null ?null :AccessController.getContext();this.corePoolSize = corePoolSize;this.maximumPoolSize = maximumPoolSize;this.workQueue = workQueue;this.keepAliveTime = unit.toNanos(keepAliveTime);this.threadFactory = threadFactory;this.handler = handler;
}
阿里巴巴 Java 开发规范里明确说明不允许使用 Executors 创建线程池,而是通过 ThreadPoolExecutor 显示指定参数去创建,Executors 创建的线程池有发生 OOM 的风险:
- Executors.newFixedThreadPool 和 Executors.SingleThreadPool 创建的线程池内部使用的是无界(Integer.MAX_VALUE)的 LinkedBlockingQueue 队列,可能会堆积大量请求,导致 OOM。
- Executors.newCachedThreadPool 和 Executors.scheduledThreadPool 创建的线程池最大线程数是用的 Integer.MAX_VALUE,可能会创建大量线程,导致 OOM。
线程池的核心参数?
- 核心线程数(corePoolSize):核心线程数,线程池中始终存活的线程数。
- 最大线程数(maximumPoolSize):最大线程数,线程池中允许的最大线程数,当线程池的任务队列满了之后可以创建的最大线程数。
- 空闲线程超时时间(keepAliveTime):最大线程数可以存活的时间,当线程中没有任务执行时,最大线程就会销毁一部分,最终保持核心线程数量的线程。
- 时间单位(unit)
- TimeUnit.DAYS:天
- TimeUnit.HOURS:小时
- TimeUnit.MINUTES:分
- TimeUnit.SECONDS:秒
- TimeUnit.MILLISECONDS:毫秒
- TimeUnit.MICROSECONDS:微妙
- TimeUnit.NANOSECONDS:纳秒
- 阻塞队列(workQueue):BlockingQueue<Runnable> workQueue
- 线程工厂(ThreadFactory):线程工厂,主要用来创建线程,默认为正常优先级、非守护线程。
- 拒绝策略(handler):拒绝处理任务时的策略,系统提供了 4 种可选:
- ThreadPoolExecutor.AbortPolicy:线程池的默认策略,直接拒绝执行和抛弃任务,并抛出运行时异常 RejectedExecutionException。如果是比较关键的业务,推荐使用此拒绝策略,这样子在系统不能承载更大的并发量的时候,能够及时的通过异常发现。
- ThreadPoolExecutor.CallerRunsPolicy:使用当前调用的线程来执行此任务,此策略提供简单的反馈控制机制,能够减缓新任务的提交速度。如果任务被拒绝了,则由调用线程(提交任务的线程)直接执行此任务。
- ThreadPoolExecutor.DiscardPolicy:丢弃任务,但是不抛出异常。如果线程队列已满,则后续提交的任务都会被丢弃,且是静默丢弃。使用此策略,可能会使我们无法发现系统的异常状态,建议是一些无关紧要的业务采用此策略。
- ThreadPoolExecutor.DiscardOldestPolicy:如果执行程序尚未关闭,则位于工作队列头部(最旧)的任务将被删除,然后重新尝试执行任务(如果再次失败,则重复此过程)。
线程池执行流程?
线程池执行流程即 execute() 方法的执行流程,execute() 方法源码如下:
public void execute(Runnable command) {if (command == null)throw new NullPointerException();int c = ctl.get();// 当前工作线程数 < 核心线程数 if (workerCountOf(c) < corePoolSize) {// 创建核心线程if (addWorker(command, true))return;// 核心线程创建失败,重新获取ctlc = ctl.get();}// 判断线程是否运行,是则将任务添加到阻塞队列中if (isRunning(c) && workQueue.offer(command)) {int recheck = ctl.get();// 再次判断线程是否运行,不是则直接删除任务,执行拒绝策略if (! isRunning(recheck) && remove(command))reject(command);// 线程试运行状态,且没有工作线程,则添加一个线程处理阻塞队列中的任务else if (workerCountOf(recheck) == 0)addWorker(null, false);// 创建非核心线程来处理任务} else if (!addWorker(command, false))// 执行拒绝策略reject(command);
}
具体执行流程如下:
- 判断线程池的状态,如果不是 RUNNING 状态,直接执行拒绝策略。
- 如果当前线程数 < 核心线程池,则新添加一个线程来处理提交的任务。
- 如果当前线程数 < 核心线程数,但是任务队列没满,则将任务放入阻塞队列等待工作线程来执行。
- 如果当前线程数 > 核心线程池,并且任务队列已满,再判断当前线程数 < 最大线程数,则创建新的线程执行提交的任务。
- 如果如果任务队列已满,而且当前线程数 > 最大线程数,则执行拒绝策略拒绝该任务。
submit() 和 execute() 区别?
execute 没有返回值,如果不需要知道线程的结果就使用 execute 方法,性能会好很多。
submit 返回一个 Future 对象,如果想知道线程结果就使用 submit 提交,而且它能在主线程中通过 Future 的 get 方法捕获线程中的异常。
线程池属性?
// 声明当前线程的状态,声明线程池中的线程数
// int类型数值:高3位是线程池状态,低29位是线程个数
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3;
// 通过位运算得出最大容量
private static final int CAPACITY = (1 << COUNT_BITS) - 1;// 线程池状态
// 111 运行状态,正常接收任务
private static final int RUNNING = -1 << COUNT_BITS;
// 000 不接受新的任务,但是内部还会处理阻塞队列中的任务和正在进行的任务
// 通过执行shutdown()方法
private static final int SHUTDOWN = 0 << COUNT_BITS;
// 001 不接受新任务,也不处理组赛对了的任务,并中断正在执行的任务
// 通过执行shutdownNow()方法
private static final int STOP = 1 << COUNT_BITS;
// 010 过渡状态,代表当前线程池即将结束
// 通过执行terminated()方法
private static final int TIDYING = 2 << COUNT_BITS;
// 011 结束状态,通过执行terminated()方法
private static final int TERMINATED = 3 << COUNT_BITS;// 得到当前线程池的状态
private static int runStateOf(int c) { return c & ~CAPACITY; }
// 得到当前线程池的线程数量
private static int workerCountOf(int c) { return c & CAPACITY; }
//
private static int ctlOf(int rs, int wc) { return rs | wc; }
阻塞队列?
缓冲队列 BlockingQueue 简介:BlockingQueue 继承 Queue,是双缓冲队列。BlockingQueue 内部使用两条队列,允许两个线程同时向队列一个存储,一个取出操作。在保证并发安全的同时,提高了队列的存取效率。
当从阻塞队列中获取数据时,如果队列为空,则等待直到队列有元素存入。当向阻塞队列中存入元素时,如果队列已满,则等待直到队列中有元素被移除。提供 offer()、put()、take()、poll() 等常用方法。
JDK 提供的阻塞队列的实现有以下几种:
- ArrayBlockingQueue(int i):由数组实现的有界阻塞队列,该队列按照 FIFO 对元素进行排序。维护两个整形变量,标识队列头尾在数组中的位置,在生产者放入和消费者获取数据共用一个锁对象,意味着两者无法真正的并行运行,性能较低。
- LinkedBlockingQueue:由链表组成的有界阻塞队列,如果不指定大小,默认使用 Integer.MAX_VALUE 作为队列大小,该队列按照 FIFO 对元素进行排序,对生产者和消费者分别维护了独立的锁来控制数据同步,意味着该队列有着更高的并发性能。
- SynchronousQueue:特殊的 BlockingQueue,不存储元素的阻塞队列,无容量,可以设置公平或非公平模式,对其的操作必须是放和取交替完成。
- PriorityBlockingQueue:类似于 LinkedBlockingQueue,支持优先级排序的无界阻塞队列,默认情况下根据自然序排序,也可以指定 Comparator。
- DelayQueue:支持延时获取元素的无界阻塞队列,创建元素时可以指定多久之后才能从队列中获取元素,常用于缓存系统或定时任务调度系统。
- LinkedTransferQueue:一个由链表结构组成的无界阻塞队列,与 LinkedBlockingQueue 相比多了 transfer 和 tryTranfer 方法,该方法在有消费者等待接收元素时会立即将元素传递给消费者。
- LinkedBlockingDeque:一个由链表结构组成的双端阻塞队列,可以从队列的两端插入和删除元素。
较常用的是 LinkedBlockingQueue 和 Synchronous,线程池的排队策略与 BlockingQueue 有关。
ThreadPoolExecutor 用到的锁?
(1)mainLock 锁
ThreadPoolExecutor 内部维护了 ReentrantLock 类型锁 mainLock,在访问 workers 成员变量以及进行相关数据统计记账(比如访问 largestPoolSize、completedTaskCount)时需要获取该重入锁。
private final ReentrantLock mainLock = new ReentrantLock();/*** Set containing all worker threads in pool. Accessed only when* holding mainLock.*/
private final HashSet<Worker> workers = new HashSet<Worker>();/*** Tracks largest attained pool size. Accessed only under* mainLock.*/
private int largestPoolSize;/*** Counter for completed tasks. Updated only on termination of* worker threads. Accessed only under mainLock.*/
private long completedTaskCount;
可以看到 workers 变量用的 HashSet 是线程不安全的,是不能用于多线程环境的。largestPoolSize、completedTaskCount 也是没用 volatile 修饰,所以需要在锁的保护下进行访问。
其他一些内部变量能用 volatile 的都加了 volatile 修饰了,这两个没加主要就是为了保证这两个参数的准确性,在获取这两个值时,能保证获取到的一定是修改方法执行完成后的值。如果不加锁,可能在修改方法还没执行完成时,此时来获取该值,获取到的就是修改前的值。
中断风暴:
简单理解就是如果不加锁,interruptIdleWorkers() 方法在多线程访问下就会发生这种情况。一个线程调用 interruptIdleWorkers() 方法对 Worker 进行中断,此时该 Worker 出于中断中状态,此时又来一个线程去中断正在中断中的 Worker 线程,这就是所谓的中断风暴。
(2)Worker 线程锁
Worker 线程继承 AQS,实现了 Runnable 接口,内部持有一个 Thread 变量,一个 firstTask,及 completedTasks 三个成员变量。
基于 AQS 的 acquire()、tryAcquire() 实现了 lock()、tryLock() 方法,类上也有注释,该锁主要是用来维护运行中线程的中断状态。在 runWorker() 方法中以及 interruptIdleWorkers() 方法中用到了。
线程池监控?
利用线程池提供的参数进行监控,参数如下:
- taskCount:线程池需要执行的任务数量。
- completedTaskCount:线程池在运行过程中已完成的任务数量,小于或等于 taskCount。
- largestPoolSize:线程池曾经创建过的最大线程数量,通过这个数据可以知道线程池是否满过。如等于线程池的最大大小,则表示线程池曾经满了。
- getPoolSize:线程池的线程数量。如果线程池不销毁的话,池里的线程不会自动销毁,所以这个大小只增不减。
- getActiveCount:获取活动的线程数。
通过扩展线程池进行监控:继承线程池并重写线程池的 beforeExecute(),afterExecute() 和 terminated() 方法,可以在任务执行前、后和线程池关闭前自定义行为。如监控任务的平均执行时间,最大执行时间和最小执行时间等。
使用线程池常见问题?
- OOM 问题。通过 Executors 创建线程,这种方式创建的线程池会有发生 OOM 的风险。
- 任务执行异常丢失问题。可以通过下述4种方式解决
- 在任务代码中增加 try、catch 异常处理
- 如果使用的 Future 方式,则可通过 Future 对象的 get 方法接收抛出的异常
- 为工作线程设置 setUncaughtExceptionHandler,在 uncaughtException 方法中处理异常
- 可以重写 afterExecute(Runnable r, Throwable t) 方法,拿到异常 t
- 共享线程池问题。整个服务共享一个全局线程池,导致任务相互影响,耗时长的任务占满资源,短耗时任务得不到执行。同时父子线程间会导致死锁的发生,进而导致 OOM。
- 跟 ThreadLocal 配合使用,导致脏数据问题。我们知道 Tomcat 利用线程池来处理收到的请求,会复用线程,如果我们代码中用到了 ThreadLocal,在请求处理完后没有去 remove,那每个请求就有可能获取到之前请求遗留的脏值。
- ThreadLocal 在线程池场景下会失效,可以考虑用阿里开源的 Ttl 来解决
Java 为程序加锁的方式?
Java 为程序加锁的方式主要有两种:synchronized 与 Lock。
- synchronized 可以修饰的作用域如下:
- 非静态方法(加的锁为对象锁);
- 静态方法(加的锁为类锁);
- 代码块(对象锁与类锁均可);
- Lock 采用 lock()对代码加锁,unlock() 进行解锁。
synchronized 锁?
synchronized 是 Java 中的关键字,是一种同步锁。
synchronized 作用:
- 原子性:所谓原子性就是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。被 synchronized 修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放。
- 可见性:可见性是指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的。synchronized 和 volatile 都具有可见性,其中 synchronized 对一个类或对象加锁时,一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到共享内存当中,保证资源变量的可见性。
- 有序性:有序性值程序执行的顺序按照代码先后执行。synchronized 和 volatile 都具有有序性,Java 允许编译器和处理器对指令进行重排,但是指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性。synchronized 保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。
synchronized 使用?
(1)修饰实例方法
作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁。
synchronized void method() {//业务代码
}
(2)修饰静态方法
synchronized 修饰静态方法相当于给当前类加锁,会作用于类的所有对象实例,进入同步代码前要获得当前类的锁。因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管 new 了多少个对象,只有一份)。
所以,如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。
synchronized void staic method() {//业务代码
}
(3)修饰代码块
指定加锁对象,对给定对象/类加锁。synchronized(this|object) 表示进入同步代码块前要获得给定对象的锁。synchronized(类.class) 表示进入同步代码前要获得当前 class 的锁。
synchronized(this) {//业务代码}
线程安全的单例模式示例:
public class Singleton {//保证有序性,防止指令重排private volatile static Singleton uniqueInstance;private Singleton() {}public static Singleton getUniqueInstance() {//先判断对象是否已经实例过,没有实例化过才进入加锁代码if (uniqueInstance == null) {//类对象加锁synchronized (Singleton.class) {if (uniqueInstance == null) {uniqueInstance = new Singleton();}}}return uniqueInstance;}
}
synchronized 的原理?
(1)synchronized 修饰同步语句块的原理:
synchronized 是由 JVM 实现的一种实现互斥同步的一种方式,如果你查看被 Synchronized 修饰过的程序块编译后的字节码,会发现被 Synchronized 修饰过的程序块,在编译前后被编译器生成了 monitorenter 和 monitorexit 两个字节码指令。
在虚拟机执行到 monitorenter 指令时,首先要尝试获取对象的锁,如果这个对象没有锁定,或者当前线程已经拥有了这个对象的锁,把锁的计数器+1;当执行 monitorexit 指令时将锁计数器-1;当计数器为0时,锁就被释放了。
如果获取对象失败了,那当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。
Java 中 Synchronize 通过在对象头设置标记,达到了获取锁和释放锁的目的。
注意:
在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由 ObjectMonitor 实现的。每个对象中都内置了一个 ObjectMonitor 对象。
wait/notify 等方法也依赖于 monitor 对象,这就是为什么只有在同步的块或者方法中才能调用 wait/notify 等方法,否则会抛出java.lang.IllegalMonitorStateException 的异常的原因。
(2)synchronized 修饰方法的原理:
synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED
标识,该标识指明了该方法是一个同步方法。JVM 通过该 ACC_SYNCHRONIZED
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。
synchronized 是可重入锁吗?
可重入性是锁的一个基本要求,是为了解决自己锁死自己的情况。
比如一个类中的同步方法调用另一个同步方法,假如 Synchronized 不支持重入,进入 method2 方法时当前线程获得锁,method2方法里面执行method1时当前线程又要去尝试获取锁,这时如果不支持重入,它就要等释放,把自己阻塞,导致自己锁死自己。
对 Synchronized 来说,可重入性是显而易见的,刚才提到,在执行 monitorenter 指令时,如果这个对象没有锁定,或者当前线程已经拥有了这个对象的锁(而不是已拥有了锁则不能继续获取),就把锁的计数器+1,其实本质上就通过这种方式实现了可重入性。
乐观锁与悲观锁?
概念:
乐观锁:
- 乐观锁在操作数据时非常乐观,认为别人不会同时修改数据。因此乐观锁不会上锁,只是在执行更新的时候判断一下在此期间别人是否修改了数据:如果别人修改了数据则放弃操作,否则执行操作。
- 传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java 中 synchronized 和 ReentrantLock 等独占锁就是悲观锁思想的实现。
悲观锁:
- 悲观锁在操作数据时比较悲观,认为别人会同时修改数据。因此操作数据时直接把数据锁住,直到操作完成后才会释放锁;上锁期间其他人不能修改数据。
- 乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于 write_condition 机制,其实都是提供的乐观锁。
-
在 Java 中 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。
实现方式:
- 乐观锁的实现方式主要有两种:CAS 机制和版本号机制。
- 悲观锁的实现方式是加锁,加锁既可以是对代码块加锁(如Java的synchronized关键字),也可以是对数据加锁(如MySQL中的排它锁)。
版本号机制:
一般是在数据表中加上一个数据版本号 version 字段,表示数据被修改的次数,当数据被修改时,version 值会加一。当线程A要更新数据值时,在读取数据的同时也会读取 version 值,在提交更新时,若刚才读取到的 version 值为当前数据库中的 version 值相等时才更新,否则重试更新操作,直到更新成功。
举一个简单的例子:假设数据库中帐户信息表中有一个 version 字段,当前值为1;而当前帐户余额字段(balance)为$100。
操作员A此时将其读出(version=1),并从其帐户余额中扣除$50($100-$50)。
在操作员A操作的过程中,操作员B也读入此用户信息(version=1),并从其帐户余额中扣除$20($100-$20)。
操作员A完成了修改工作,将数据版本号加一(version=2),连同帐户扣除后余额(balance=$50),提交至数据库更新,此时由于提交数据版本大于数据库记录当前版本,数据被更新,数据库记录 version 更新为2。
操作员B完成了操作,也将版本号加一(version=2)试图向数据库提交数据(balance=$80),但此时比对数据库记录版本时发现,操作员B提交的数据版本号为2,数据库记录当前版本也为2,不满足“提交版本必须大于记录当前版本才能执行更新“的乐观锁策略,因此,操作员B的提交被驳回。
这样,就避免了操作员B用基于 version=1 的旧数据修改的结果覆盖操作员A的操作结果的可能。
CAS算法:
即 compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。
CAS 算法涉及到三个操作数
- 需要读写的内存值 V
- 进行比较的值 A
- 拟写入的新值 B
当且仅当 V 的值等于 A 时,CAS 通过原子方式用新值 B 来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。
乐观锁的缺点:
- ABA问题
如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回A,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的"ABA"问题。
JDK1.5 以后的 AtomicStampedReference 类就提供了此种能力,其中的 compareAndSet 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
- 循环时间长开销大
自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给 CPU 带来非常大的执行开销。
如果 JVM 能支持处理器提供的 pause 指令那么效率会有一定的提升,pause 指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。
- 只能保证一个共享变量的原子操作
CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK1.5 开始,提供了 AtomicReference 类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作。所以我们可以使用锁或者利用 AtomicReference 类把多个共享变量合并成一个共享变量来操作。
CAS 与 synchronized 的使用情景:
简单的来说CAS适用于写比较少的情况下(多读场景,冲突一般较少),synchronized适用于写比较多的情况下(多写场景,冲突一般较多)
- 对于资源竞争较少(线程冲突较轻)的情况,使用 synchronized 同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗 CPU 资源;而CAS 基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
- 对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized。
补充:
Java并发编程这个领域中synchronized关键字一直都是元老级的角色,很久之前很多人都会称它为“重量级锁”。
但是,在 JavaSE 1.6 之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁以及其它各种优化之后变得在某些情况下并不是那么重了。
synchronized 的底层实现主要依靠 Lock-Free 的队列,基本思路是自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和CAS类似的性能;而线程冲突严重的情况下,性能远高于CAS。
当一个线程进入一个对象的 synchronized 方法后,其它线程是否可进入此对象的其它方法?
- 其他方法前是否加了 synchronized 关键字,如果没加,则能。
- 如果这个方法内部调用了 wait,则可以进入其他 synchronized 方法。
- 如果其他个方法都加了 synchronized 关键字,并且内部没有调用 wait,则不能。
- 如果其他方法是 static,它用的同步锁是当前类的字节码,与非静态的方法不能同步,因为非静态的方法用的是 this。
关键字 volatile?
关键字 volatile 是 Java 虚拟机提供的最轻量级的同步机制。当一个变量被定义成 volatile 之后,具备两种特性:
- 保证此变量对所有线程的可见性。当一条线程修改了这个变量的值,新值对于其他线程是可以立即得知的。而普通变量做不到这一点。
- 禁止指令重排序优化。普通变量仅仅能保证在该方法执行过程中,得到正确结果,但是不保证程序代码的执行顺序。
volatile 保证可见性,有序性?
volatile 可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在 JVM 底层 volatile 是采用“内存屏障”来实现的。
观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码发现,加入volatile 关键字时,会多出一个 lock 前缀指令,lock 前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供 3 个功能:
- 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
- 它会强制将对缓存的修改操作立即写入主存;
- 如果是写操作,它会导致其他 CPU 中对应的缓存行无效。
volatile 和 atomic ?
volatile 多用于修饰类似开关类型的变量、Atomic 多用于类似计数器相关的变量、其它多线程并发操作用synchronized 关键字修饰。
volatile 类型变量提供什么保证?能使得一个非原子操作变成原子操作吗?
- volatile 提供 happens-before 的保证,确保一个线程的修改能对其他线程是可见的。在Java 中除了 long 和 double 之外的所有基本类型的读和赋值,都是原子性操作。
- 而64位的 long 和 double 变量由于会被 JVM 当作两个分离的32位来进行操作,所以不具有原子性,会产生字撕裂问题。但是当你定义 long 或 double 变量时,如果使用 volatile 关键字,就会获到(简单的赋值与返回操作的)原子性。
volatile 和 Synchronized 的异同?
- Synchronized 既能保证可见性,又能保证原子性,而 volatile 只能保证可见性,无法保证原子性。
ThreadLocal 和 Synchonized?
- ThreadLocal 和 Synchonized 都用于解决多线程并发访问,防止任务在共享资源上产生冲突。但是 ThreadLocal 与 Synchronized 有本质的区别。
- Synchronized 用于实现同步机制,是利用锁的机制使变量或代码块在某一时该只能被一个线程访问,是一种“以时间换空间”的方式。
- 而 ThreadLocal 为每一个线程都提供了变量的副本,使得每个线程在某一时间访问到的并不是同一个对象,根除了对变量的共享,是一种“以空间换时间”的方式。
sychronized 和 ReentrantLock 的区别?
- synchronized 是和 if、else、for、while 一样的关键字,ReentrantLock 是类,这是二者的本质区别。既然 ReentrantLock 是类,那么它就提供了比 synchronized 更多更灵活的特性,可以被继承、可以有方法、可以有各种各样的类变量。
- synchronized 通过在对象头中设置标记实现这一目的,是一种JVM原生的锁实现方式,而 ReentrantLock 以及所有的基于Lock接口的实现类,都是通过用一个volitile修饰的int型变量,并保证每个线程都能拥有对该int的可见性和原子修改,其本质是基于所谓的AQS框架。
- ReentrantLock 比 synchronized 的扩展性体现在几点上:
- ReentrantLock 可以对获取锁的等待时间进行设置,这样就避免了死锁
- ReentrantLock 可以获取各种锁的信息
- ReentrantLock 可以灵活地实现多路通知
- 另外,二者的锁机制其实也是不一样的。ReentrantLock 底层调用的是 Unsafe 的 park 方法加锁,synchronized 操作的应该是对象头中 markword,这点我不能确定。
- ReentrantLock 显示获得、释放锁,synchronized 隐式获得释放锁
- ReentrantLock 可响应中断、可轮回,synchronized 是不可以响应中断的,为处理锁的不可用性提供了更高的灵活性
- ReentrantLock 是 API 级别的,synchronized 是 JVM 级别的
- ReentrantLock 可以实现公平锁
- ReentrantLock 通过 Condition 可以绑定多个条件
Lock 和 synchronized 区别?
- Lock 能完成 synchronized 所实现的所有功能。
- Lock 有比 synchronized 更精确的线程语义和更好的性能。
- synchronized 会自动释放锁,而 Lock 一定要求程序员手工释放,并且必须在 finally 从句中释放。
- lock 接口在多线程和并发编程中最大的优势是它们为读和写分别提供了锁,它能满足你写像 ConcurrentHashMap 这样的高性能数据结构和有条件的阻塞。
- Lock 还有更强大的功能,例如,它的 tryLock 方法可以非阻塞方式去拿锁,示例如下:
public class ThreadTest {private int j;private Lock lock = new ReentrantLock();public static void main(String[] args) {ThreadTest tt = new ThreadTest();for (int i = 0; i < 2; i++) {new Thread(tt.new Adder()).start();new Thread(tt.new Subtractor()).start();}}private class Subtractor implements Runnable {@Overridepublic void run() {while (true) {lock.lock();try {System.out.println("j--=" + j--);} finally {lock.unlock();}}}}private class Adder implements Runnable {@Overridepublic void run() {while (true) {lock.lock();try {System.out.println("j++=" + j++);} finally {lock.unlock();}}}}
}
ReentrantLock 如何实现可重入性?
ReentrantLock内部自定义了同步器Sync(Sync既实现了AQS,又实现了AOS,而AOS提供了一种互斥锁持有的方式),其实就是加锁的时候通过CAS算法,将线程对象放到一个双向链表中,每次获取锁的时候,看下当前维护的那个线程ID和当前请求的线程ID是否一样,一样就可重入了。
偏向锁/轻量级锁/自旋锁/重量级锁?
(1)偏向锁
偏向锁是JDK6中的重要引进,因为HotSpot作者经过研究实践发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,引进了偏向锁。
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。
如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时, 持有偏向锁的线程才会释放锁。
偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着, 如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
下图中的线程1演示了偏向锁初始化的流程,线程2演示了偏向锁撤销的流程:
(2)轻量级锁
引入轻量级锁的主要目的是 在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁。
轻量级锁加锁:
线程在执行同步块之前,JVM 会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的 Mark Word 复制到锁记录中,官方称为 Displaced Mark Word。然后线程尝试使用 CAS 将对象头中的 Mark Word 替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
轻量级锁解锁:
轻量级解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
两个线程同时争夺锁,导致锁膨胀的流程图:
(3)自旋锁
如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
(4)重量级锁
要进入一个同步、线程安全的方法时,是需要先获得这个方法的锁的,退出这个方法时,则会释放锁。如果获取不到这个锁的话,意味着有别的线程在执行这个方法,这时我们就会马上进入阻塞的状态,等待那个持有锁的线程释放锁,然后再把我们从阻塞的状态唤醒,我们再去获取这个方法的锁。这种获取不到锁就马上进入阻塞状态的锁,我们称之为重量级锁。
锁升级、锁消除、锁粗化?
(1)锁升级
锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁。但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。
(2)锁消除
锁消除(lock eliminate):指虚拟机即时编译器在运行时,对一些代码上要求同步,但被检测到不可能存在共享数据竞争的锁进行消除。
(3)锁粗化
锁粗化(lock coarsening):原则上,同步块的作用范围要尽量小。但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作在循环体内,频繁地进行互斥同步操作也会导致不必要的性能损耗。锁粗化就是增大锁的作用域。
什么是 AQS?
AQS(AbstractQueuedSynchronizer 类)是一个 Java 提高的底层同步工具类,用一个 int 类型的变量表示同步状态,并提供了一系列的CAS操作来管理这个同步状态。
AQS(AbstractQueuedSynchronizer 类)是一个用来构建锁和同步器的框架,各种Lock包中的锁(常用的有ReentrantLock、ReadWriteLock),以及其他如Semaphore、CountDownLatch,甚至是早期的 Future Task 等,都是基于AQS来构建。
AQS 在内部定义了一个 volatile int state 变量,表示同步状态,当线程调用 lock 方法时:
- 如果 state=0,说明没有任何线程占有共享资源的锁,可以获得锁并将 state=1;
- 如果 state=1,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列进行等待。
AQS 通过 Node 内部类构成的一个双向链表结构的同步队列,来完成线程获取锁的排队工作,当有线程获取锁失败后,就被添加到队列末尾。
- Node 类是对要访问同步代码的线程的封装,包含了线程本身及其状态叫 wait Status(有五种不同取值,分别表示是否被阻塞,是否等待唤醒,是否已经被取消等),每个 Node 结点关联其 prev 结点和 next 结点,方便线程释放锁后快速唤醒下一个在等待的线程,是一个FIFO的过程。
- Node类有两个常量,SHARED和EXCLUSIVE,分别代表共享模式和独占模式。所谓共享模式是一个锁允许多条线程同时操作(信号量Semaphore就是基于AQS的共享模式实现的),独占模式是同一个时间段只能有一个线程对共享资源进行操作,多余的请求线程需要排队等待(如ReentranLock)。
AQS通过内部类 Condition Object 构建等待队列(可有多个),当 Condition 调用 wait() 方法后,线程将会加入等待队列中,而当 Condition 调用 signal() 方法后,线程将从等待队列转移动同步队列中进行锁竞争。
AQS和Condition各自维护了不同的队列,在使用Lock和Condition的时候,其实就是两个队列的互相移动。
AQS 两种同步方式?
- 独占式
- 共享式
这样方便使用者实现不同类型的同步组件,独占式如ReentrantLock,共享式如Semaphore,CountDownLatch,组合式的如ReentrantReadWriteLock。总之,AQS为使用提供了底层支撑,如何组装实现,使用者可以自由发挥。
什么是 CAS?
CAS 是 compare and swap 的缩写,即我们所说的比较交换。
CAS 是一种基于锁的操作,而且是乐观锁。在java中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加 version 来获取数据,性能较悲观锁有很大的提高。
CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新成B。CAS是通过无限循环来获取数据的,如果在第一轮循环中,a 线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。
如下代码:
// AtomicInteger
AtomicInteger atomicInteger = new AtomicInteger();
atomicInteger.getAndIncrement();// atomicInteger.getAndIncrement();方法底层
public final int getAndIncrement() {return unsafe.getAndAddInt(this, valueOffset, 1);
}// unsafe.getAndAddInt 底层
public final int getAndAddInt(Object var1, long var2, int var4) {int var5;do {var5 = this.getIntVolatile(var1, var2);} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));return var5;
}
CAS 的问题?
(1)CAS 容易造成 ABA 问题
一个线程a将数值改成了b,接着又改成了a,此时 CAS 认为是没有变化,其实是已经变化过了,而这个问题的解决方案可以使用版本号标识,每操作一次 version 加1。在 java5 中,已经提供了 AtomicStampedReference 来解决问题。
(2)不能保证代码块的原子性
CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用 synchronized 了。
(3)CAS 造成 CPU 利用率增加
之前说过了 CAS 里面是一个循环判断的过程,如果线程一直没有获取到状态,cpu 资源会一直被占用。
synchronized、volatile、CAS 比较?
synchronized 是悲观锁,属于抢占式,会引起其他线程阻塞。
volatile 提供多线程共享变量可见性和禁止指令重排序优化。
CAS 是基于冲突检测的乐观锁(非阻塞)
CycliBarriar 和 CountdownLatch 区别?
- CountDownLatch 简单的说就是一个线程等待,直到他所等待的其他线程都执行完成并且调用 countDown() 方法发出通知后,当前线程才可以继续执行。
- cyclicBarrier 是所有线程都进行等待,直到所有线程都准备好进入 await() 方法之后,所有线程同时开始执行!
- CountDownLatch 的计数器只能使用一次。而 CyclicBarrier 的计数器可以使用 reset() 方法重置。所以CyclicBarrier 能处理更为复杂的业务场景,比如如果计算发生错误,可以重置计数器,并让线程们重新执行一次。
- CyclicBarrier 还提供其他有用的方法,比如 getNumberWaiting 方法可以获得 CyclicBarrier 阻塞的线程数量。isBroken 方法用来知道阻塞的线程是否被中断。如果被中断返回 true,否则返回 false。
ReadWriteLock 和 StampedLock?
虽然ReentrantLock和Synchronized简单实用,但是行为上有一定局限性,要么不占,要么独占。实际应用场景中,有时候不需要大量竞争的写操作,而是以并发读取为主,为了进一步优化并发操作的粒度,Java提供了读写锁。
读写锁基于的原理是多个读操作不需要互斥,如果读锁试图锁定时,写锁是被某个线程持有,读锁将无法获得,而只好等待对方操作结束,这样就可以自动保证不会读取到有争议的数据。
ReadWriteLock代表了一对锁,下面是一个基于读写锁实现的数据结构,当数据量较大,并发读多、并发写少的时候,能够比纯同步版本凸显出优势:
读写锁看起来比Synchronized的粒度似乎细一些,但在实际应用中,其表现也并不尽如人意,主要还是因为相对比较大的开销。
所以,JDK在后期引入了StampedLock,在提供类似读写锁的同时,还支持优化读模式。优化读基于假设,大多数情况下读操作并不会和写操作冲突,其逻辑是先试着修改,然后通过validate方法确认是否进入了写模式,如果没有进入,就成功避免了开销;如果进入,则尝试获取读锁。
单例模式的线程安全性?
单例模式的线程安全意味着:某个类的实例在多线程环境下只会被创建一次出来。
单例模式种类:
- 饿汉式单例模式的写法:线程安全
- 懒汉式单例模式的写法:非线程安全
- 双检锁单例模式的写法:线程安全
JVM
对象在内存中的存储布局?
普通对象 new XXX()
- 对象头 markword
- 类型指针 class pointer
- 实例数据 instance data
- 对齐 padding
数组 int[] a = new int[4]、T[] a = new T[5]
- 对象头 markword
- 类型指针 class pointer
- 数组长度 length(4字节)
- 实例数据 instance data
- 对齐 padding
Object o = new Object() 在内存中占用字节16个
- 对象头:8个字节
- 类型指针:4个字节(压缩是4个字节,不压缩是8个字节,java 默认开启压缩)
- 实例数据:0个字节
- 对齐:4个字节(8+4=12不能被8整除,所以补4个字节到16)
所以占用字节是16个字节,不压缩的时候,不需要对齐,所以还是16个字节
User user = new User() 在内存中占用字节24个
User {private int id;private String name;
}
User user = new User()
- 对象头:8个字节
- 类型指针:4个字节
- 实例数据:8个字节
- int 类型占用4个字节
- String 类型占用4个字节
- 对齐:4个字节(8+4+4+4=20不能被8整除,所以补4个字节到24)
所以压缩的情况下占用字节是24个字节
使用如下工具来测试
<dependency><groupId>org.openjdk.jol</groupId><artifactId>jol-core</artifactId><version>0.16</version>
</dependency>
测试如下:
class User{private int id;private String name;
}class Grade{private int id;private String name;private User user;
}@Test
public void test1(){Object object = new Object();System.out.println(ClassLayout.parseInstance(object).toPrintable());User user = new User();System.out.println(ClassLayout.parseInstance(user).toPrintable());Grade grade = new Grade();System.out.println(ClassLayout.parseInstance(grade).toPrintable());
}
输出结果
再看如下实示例:
@Test
public void test1(){Object object = new Object();System.out.println(ClassLayout.parseInstance(object).toPrintable());synchronized (object){System.out.println(ClassLayout.parseInstance(object).toPrintable());}
}
从上面的结果可以看出 synchronized 的信息是存在 markword 中的。