一、Java基础知识
1 什么是面向对象,谈谈你对面向对象的理解
对比面向过程,是两种不同的处理问题的角度,面向过程更注重事情的每一个步骤及顺序,面向对象更注重事情有哪些参与者、及各自需要做什么。
三大特性:封装、继承、多态
- 封装:封装的意义,在于明确标识出允许外部使用的所有成员函数和数据项,内部细节对外部调用透明,外部调用无需修改或者关心内部实现。例:
JavaBean
- 继承:继承基类的方法,并做出自己的改变和/或扩展。子类共性的方法或者属性直接使用父类的,而不需要自己再定义,只需扩展自己个性化的。
- 多态:基于对象所属类的不同,外部对同一个方法的调用,实际执行的逻辑不同。关键在于继承、方法重写,并且父类引用指向子类对象,但无法调用子类特有的功能。
父类类型 变量名 = new 子类对象;
变量名.方法名();
2 ==和equals比较
==
对比的是栈中的值,基本数据类型是变量值,引用类型是堆中内存对象的地址
equals
:object
中默认也是采用==
比较,通常会重写
public class StringDemo {
public static void main(String args[]) {
String str1 = "Hello";
String str2 = new String("Hello");
String str3 = str2;
System.out.println(str1 == str2);//false
System.out.println(str1 == str3);//false
System.out.println(str2 == str3);//true
System.out.println(str1.equals(str2));//true
System.out.println(str1.equals(str3));//true
System.out.println(str2.equals(str3));//true
}
}
3 Java的char是两个字节,是怎么存UTF-8字符的
UTF-8、ASCII码、Unicode
- ASCII码
128个字符的编码,占用了一个字节(一个字节为8位256种状态)的后面7位,最前面的1位统一规定为0。
- Unicode
Unicode是一种所有符号的编码,是一个符号集,它规定了符号的二进制代码,没有规定这个二进制代码应该如何存储。它造成的结果是:
- 出现了Unicode的多种存储方式,也就是说有许多种不同的二进制格式,可以用来表示Unicode。
- Unicode在很长一段时间内无法推广。
- UTF-8、UTF-16
UTF-8是Unicode的实现方式之一。UTF-8最大的一个特点,就是它是一种变长的编码方式。它可以使用1~4个字节表示一个符号,根据不同的符号而变化字节长度。对于英语字母,UTF-8编码和ASCII码是相同的。UTF-8最小单位为1个字节,UTF-16最小单位为2个字节,char用UTF-16存储。
一个utf-8数字占1个字节,一个utf-8英文字母占1个字节,少数汉字每个占用3个字节,多数占用4个字节。字符串长度不等于字符数(如emoji字符)
总结
- Java char不存储UTF-8的字节,而是UTF-16的
- Unicode是字符集,不是编码,作用类似于ASCII码
- Java String的length不是字符数
4 Java String可以有多长
- 字符串有多长是指字符数还是字节数
- 字符串有几种存在形式
- 字符串的不同形式受到何种限制
字符串有几种存在形式
- 栈
源文件:.java文件
`String longString = "aaa...aaa";`
字节码:.class文件
CONSTANT_Utf8_info{
u1 tag;
//0~65535实际存储65535个latin字符会报错,原因是编译器判断时用的是'<',kotlin可以存储65535
//非latin字符可以存储65535,因为编译器对于汉字的判断用'>'
u2 length;
u1 bytes[length];//最多65535个字节
}
存储在虚拟机方法区的常量池中。
- 堆
byte[] bytes = loadFromFile(new File("superLongText.txt"));
String superLongString new String(bytes);
受到虚拟机指令限制,字符数理论上限为Integer.MAX_VALUE,实际上限可能小于Integer.MAX_VALUE,如果堆内存较小,也会受到堆内存的限制。
总结
Java String字面量形式
- 字节码中CONSTANT_Utf8_info的限制
- Javac源码逻辑的限制
- 方法区大小的限制
Java String运行时创建在堆上的形式
- Java虚拟机指令newarray的限制
- Java虚拟机堆内存大小的限制
5 final有什么作用,为什么局部内部类和匿名内部类只能访问局部final变量,匿名内部类有什么限制
- 修饰类:表示类不可被继承
- 修饰方法:表示方法不可被子类覆盖
- 修饰变量:表示变量一旦被赋值就不可以更改,如果是引用类型的变量,初始化后不能再让其指向另一个对象,但是引用的值是可变的
public class FinalReferenceTest {
public static void main() {
final Person p = new Person(25);
p.setAge(24);//合法
p = null;//非法
}
}
当外部类的方法运行结束时,方法中的局部变量就销毁了,但是内部类对象可能还存在,这时内部类对象就会访问一个不存在的变量。为了解决这个问题,就将局部变量复制了一份作为内部类的成员变量,这样当局部变量死亡后,内部类访问的是局部变量的快照。所以为了保证局部变量和内部类的成员变量一致,需要将局部变量设置为final。
- 匿名内部类的名字
外部类+$N,N是匿名内部类的顺序
- 匿名内部类的构造方法
第一种情形:
public class Client {
public void run() {
InnerClass innerClass = new Outerclass().new InnerClass(){...};
}
}
public class OuterClass {
public abstract class InnerClass {
abstract void test();
}
}
编译结果:
public class Client$1{
//非静态方法有自己的外部类实例引用,也有非静态父类的外部类实例引用
public Client$1(Client client, OuterClass outerClass){...}
}
第二种情形:
public class Client {
public void run() {
InnerClass innerClass = new Outerclass().InnerClass(){...};
}
}
public class OuterClass {
public interface InnerClass {
void test();
}
}
编译结果:
public class Client$1{
public Client$1(Client client){...}
}
第三种情形(静态方法):
public class Client {
public static void run() {
InnerClass innerClass = new Outerclass().InnerClass(){...};
}
}
public class OuterClass {
public interface InnerClass {
void test();
}
}
编译结果:
public class Client$1{
public Client$1(){...}
}
捕获外部变量:
public class Client {
public static void run() {
final Object object = new Object();
InnerClass innerClass = new Outerclass().InnerClass(){
@Override
void test() {
System.out.println(object.toString());
}
};
}
}
public class OuterClass {
public interface InnerClass {
void test();
}
}
编译结果:
public class Client$1{
public Client$1(Object object){...}
}
匿名内部类的构造方法总结
- 编译器生成
- 参数列表包括:
· 外部对象(定义在非静态域内)
· 父类的外部对象(父类非静态)
· 父类的构造方法参数(父类有构造方法且参数列表不为空)
· 外部捕获的变量(方法体内有引用外部final变量)
- Lambda转换(SAM类型):single abstract method,只能代替接口类型并只能有一个方法
总结
- 没有人类认知意义上的名字
- 只能继承一个父类或实现一个接口
- 父类是非静态的类型,则需父类外部实例来初始化
- 如果定义在非静态作用域内,会引用外部类实例
- 只能捕获外部作用域内的final变量
- 创建时只有单一方法的接口可以用Lambda转换
6 String、StringBuffer、StringBuilder区别及源码分析
- String
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
...
}
String
是final
修饰的,不可变,每次操作都会产生新的String
对象。
例如String s = new String(“xyz”);
的执行过程是,先在常量池中找xyz
这个对象,如果没有,先在常量池中创建这个字符串对象,然后在堆中创建常量池中这个对象的拷贝对象,栈中的局部变量s
再指向堆中的对象。所以,执行这个语句有可能产生一个(常量池已经有xyz
)或两个(常量池中没有xyz
)对象。
- StringBuilder/StringBuffer
public final class StringBuffer
extends AbstractStringBuilder
implements java.io.Serializable, CharSequence
{
/**
* Constructs a string buffer with no characters in it and an
* initial capacity of 16 characters.
*/
public StringBuffer() {
super(16);
}
...
}
public final class StringBuilder
extends AbstractStringBuilder
implements java.io.Serializable, CharSequence
{
/**
* Constructs a string builder with no characters in it and an
* initial capacity of 16 characters.
*/
public StringBuilder() {
super(16);
}
}
abstract class AbstractStringBuilder implements Appendable, CharSequence {
/**
* The value is used for character storage.
*/
char[] value;
}
可以发现Stringbuilder
和Stringbuffer
都是继承了abstractStringbuilder
这个抽象类,都是通过一个char
类型的数组进行存储字符串的,但是是String
类中的char
数组是final
修饰的,是不可变的,而StringBuilder
和StringBuffer
中的char
数组没有被final
修饰,是可变的。
再看两个类的append
方法,易知StringBuffer是线程安全的,而StringBuilder是线程不安全的
@Override
public synchronized StringBuffer append(Object obj) {
toStringCache = null;
super.append(String.valueOf(obj));
return this;
}
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null;
super.append(str);
return this;
}
@Override
public StringBuilder append(Object obj) {
return append(String.valueOf(obj));
}
@Override
public StringBuilder append(String str) {
super.append(str);
return this;
}
public AbstractStringBuilder append(String str) {
if (str == null)
return appendNull();
int len = str.length();
ensureCapacityInternal(count + len);
str.getChars(0, len, value, count);
count += len;//非原子操作,导致线程不安全
return this;
}
private void ensureCapacityInternal(int minimumCapacity) {
// overflow-conscious code
if (minimumCapacity - value.length > 0) {
value = Arrays.copyOf(value,
newCapacity(minimumCapacity));
}
}
private int newCapacity(int minCapacity) {
// overflow-conscious code
//新数组的容量是原来的2倍+2
int newCapacity = (value.length << 1) + 2;
if (newCapacity - minCapacity < 0) {
newCapacity = minCapacity;
}
return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0)
? hugeCapacity(minCapacity)
: newCapacity;
}
源码中可以看出,如果原数组容纳不下新的字符串,将会创建一个新数组,其大小是原数组大小的两倍+2。
7 重载和重写的区别,怎样理解Java的方法分派
重载:发生在同一个类中,方法名必须相同,参数类型不同、个数不同、顺序不同、方法返回值和访问修饰符可以不同,发生在编译时。重载的条件是,参数类型不同、参数个数不同、参数顺序不同。而返回值不同不可以构成重载,原因是调用方法时往往是忽略返回值的,此时编译器就不能判断调用哪个方法,容易造成错误。
重写:发生在父子类中,方法名、参数列表必须相同,返回值范围小于等于父类(有返回值的可以改成void
),抛出的异常范围小于等于父类,访问修饰符范围大于等于父类(protected
修饰符可以改成public
),如果父类访问修饰符为private则子类就不能重写该方法。
class SuperClass {
public String getName() {
return "Super"'
}
}
class SubClass {
public String getName() {
return "Sub"'
}
}
public class Question4 {
public static void main(String... args) {
SuperClass superClass = new SubClass();
//调用的重载方法取决于声明类型
printHello(superClass);
}
public static void printHello(SuperClass superClass) {
//调用的覆写方法取决于运行的实际类型
System.out.println("Hello " + superClass.getName());
}
public static void printHello(SubClass subClass) {
System.out.println("Hello " + subClass.getName());
}
}
- 静态分派-方法重载分派:编译期确定,依据调用者的声明类型和方法参数类型
- 动态分派-方法覆写分派:运行时确定,依据调用者的实际类型分派
8 接口与抽象类
- 抽象类中可以有普通成员方法,而接口中只能存在
public abstract
方法 - 抽象类中的成员变量可以是各种类型的,而接口中的成员变量只能是
public static final
类型的 - 抽象类只能继承一个,借口可以实现多个
9 ArrayList、LinkedList源码分析
ArrayList
- 构造方法
/**
* Shared empty array instance used for empty instances.
*/
private static final Object[] EMPTY_ELEMENTDATA = {};
/**
* Shared empty array instance used for default sized empty instances. We
* distinguish this from EMPTY_ELEMENTDATA to know how much to inflate when
* first element is added.
*/
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
/**
* The array buffer into which the elements of the ArrayList are stored.
* The capacity of the ArrayList is the length of this array buffer. Any
* empty ArrayList with elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA
* will be expanded to DEFAULT_CAPACITY when the first element is added.
*/
// Android-note: Also accessed from java.util.Collections
transient Object[] elementData; // non-private to simplify nested class access
/**
* Constructs an empty list with an initial capacity of ten.
*/
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
/**
* Constructs an empty list with the specified initial capacity.
*
* @param initialCapacity the initial capacity of the list
* @throws IllegalArgumentException if the specified initial capacity
* is negative
*/
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
}
}
构造方法主要用到上述两种,分别指定与不指定初始容量,而两种构造方法在构造初始容量为0时分别使用了两个不同的空数组EMPTY_ELEMENTDATA
和DEFAULTCAPACITY_EMPTY_ELEMENTDATA
,根据注释表明其用来区分加入第一个元素时用到不同的扩容策略。
- 扩容策略
private static final int DEFAULT_CAPACITY = 10;
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
private void ensureExplicitCapacity(int minCapacity) {
//ArrayList被修改的次数,用于Fail-Fast机制检测
modCount++;
// 容量不足,需要扩容
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;//当前容量
int newCapacity = oldCapacity + (oldCapacity >> 1);//设定扩容到当前的1.5倍
if (newCapacity - minCapacity < 0)//如果扩容两倍仍然不够,则扩容到所需容量
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);//新建一个数组,将原数组复制到新数组,完成扩容
}
对于不指定初始容量的ArrayList
(即空数组采用DEFAULTCAPACITY_EMPTY_ELEMENTDATA
),最小容量取10和当前需要容量的最大值,否则将数组大小直接扩容至所需最小容量。由扩容机制可知,ArrayList
扩容时需要将原来的数组拷贝到新数组,效率较低,所以ArrayList
不适用于add
次数过多的场景。
Fail-Fast机制懒得整理了,我的理解是,在迭代时修改了Collection,导致迭代失败,不限于单线程和多线程。贴个参考文章:Fail-Fast机制
链接文章中提到的避免出现Fail-Fast的方法:
1.采用迭代器的修改方法而不是集合类的修改方法进行修改
2.采用java并发包(java.util.concurrent)中的类来代替 ArrayList 和hashMap
LinkedList
- 构造方法
public LinkedList() {
}
LindedList
构造方法是一个空方法【摊手.gif
- add
public boolean add(E e) {
linkLast(e);
return true;
}
void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}
添加元素时,分配空间,并将元素放入末端。性能实验中发现顺序添加元素时LinkedList
以不同量级远差于ArrayList
,盲猜就是由于每一次添加元素LinkedList
都要分配空间。
总结
- ArrayList因为是基于动态数组去实现,在随机存取时,有着良好的性能。而增删时需要扩容,整块移动元素,所以相对较慢。但在数据量很大,顺序添加时是个例外,这种情况下它的性能优于LinkedList。
- LinkedList因为是基于链表实现,随机增删较快,而存取时需要遍历查询,相对于ArrayList会更慢。
10 HashMap、HashTable区别,HashMap源码分析
概述
HashMap
方法没有synchronized
修饰,线程非安全,HashTable
线程安全HashMap
允许key
和value
为null
,而HashTable
不允许
HashMap源码分析
- 构造方法
transient Node<K,V>[] table;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int MAXIMUM_CAPACITY = 1 << 30;
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
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);
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
构造方法有四个,分别可以设置初始容量、负载因子等,其中有几个重要参数:
loadFactor
:默认为0.75threshold
:扩容阈值,计算方法为哈希桶长度*loadFactor
MAXIMUM_CAPACITY
:最大容量,为2的30次方DEFAULT_INITIAL_CAPACITY
:默认初始容量为16
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
此方法将初始容量转换为2的n次方的形式,转换方式为将初始容量从第一位算起,所有低位都用1填满,然后再加1,最后将2的n次方形式的计算结果返回给哈希桶扩容阈值
put
方法
static final int TREEIFY_THRESHOLD = 8;
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//判断哈希表是否为空,为空则直接扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//(n-1)&hash为取模运算,相当于hash%length,但效率更高,这里用于判断节点是否已有值
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//hash值相等,key也相等,则直接覆盖此键值对
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//不是覆盖操作,且当前哈希值下为红黑树的处理
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//不是覆盖操作,而是发生了哈希冲突
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//遍历当前哈希值下的链表,找不到相同key值,则在结尾增加一个节点
p.next = newNode(hash, key, value, null);
//链表长度大于等于8,转换为红黑树处理
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//找到相应key值的节点,覆盖value值
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;//用于fail-fast机制检验集合修改次数
if (++size > threshold)//超过扩容阈值,进行扩容
resize();
afterNodeInsertion(evict);//官方注释为:Callbacks to allow LinkedHashMap post-actions
return null;
}
- 扩容方法
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;//旧容量
int oldThr = threshold;//旧扩容阈值
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//不超过最大容量的话扩容到原来的2倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1;
}
else if (oldThr > 0) //hashmap为空但有阈值,说明是构造方法初始化时指定了
newCap = oldThr;//新容量指定为旧阈值
else {//构造方法未进行设置,则指定为默认初始容量为16,默认阈值为12
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];//构建新哈希桶
table = newTab;
if (oldTab != null) {//将原哈希桶中的元素复制过来,过程与put过程类似,不同主要是,由于扩容到原来的两倍,所以发生哈希碰撞后产生链表后的节点,会被分配到原位(低位)或者两倍处的高位
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {//利用哈希值&旧的容量,可以得到哈希值去模后,是大于等于oldCap还是小于oldCap,等于0代表小于oldCap,应该存放在低位,否则存放在高位
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {//将低位链表存放在原位
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {//将高位链表存放在高位
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
11 ConcurrentHashMap原理
先贴一篇别人的分析:ConcurrentHashMap源码分析
- JDK7、JDK8的区别
JDK7中的ConcurrentHashMap
由Segment
和HashEntry
组成,把哈希桶数组切分成小数组,然后给每一段数据配一把锁,当一个线程占用锁访问其中一段数据时,其他段的数据也能被其他线程访问,实现并发访问。
JDK8中的ConcurrentHashMap
选择了与HashMap
相同的Node
数组+链表+红黑树的结构。在锁的实现上,抛弃了原有的Segment
分段锁,采用CAS
+synchronized
实现更加细粒度的锁,将锁的级别控制在了哈希桶数组元素级别,只需要锁住这个链表头节点(红黑树的根节点),就不会影响其他哈希桶数组元素的读写,大大提高了并发度
ConcurrentHashMap
的get
方法需要加锁吗
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
volatile V val;
volatile Node<K,V> next;
}
由于Node
的元素val
和指针next
用volatile
修饰,在多线程环境下线程A修改节点的value
或者新增节点对线程B可见,所以get
方法不需要加锁
ConcurrentHashMap
不支持key
或value
为null
的原因是什么
对key
不作过多讨论,可能是作者习惯问题。
如果value
为空,多线程环境下无法判断key
不存在还是值为空,而单线程环境下,HashMap
可以用containsKey(key)
判断是否包含这个key
,多线程环境下无法保证containsKey
方法的同步性
- 与
HashMap
迭代器强一致性不同,ConcurrentHashMap
迭代器是弱一致性的
在改变时会new
出新的数据从而不影响原有的数据 ,iterator
完成后再将头指针替换为新的数据,这样读线程可以使用老数据进行遍历,写线程可以并发地完成数据修改,提升了并发性
12 Java四大引用
- 强引用
使用最普遍的引用(new
),一个对象具有强引用,不会被垃圾回收器回收。当内存空间不足,Java虚拟机抛出OOM错误,使程序异常终止,也不会回收这种对象。
- 弱引用
JVM进行垃圾回收时,无论内存是否充足,都会回收被弱引用关联的对象。可以在缓存中使用弱引用。
- 软引用
如果一个对象具有软引用,内存空间足够,垃圾回收器就不会回收它。软引用可用来实现内存敏感的高速缓存,比如网页缓存、图片缓存等。使用软引用能防止内存泄露,增强程序的健壮性。
- 虚引用
虚引用是四种引用中最弱的一种引用。我们永远无法从虚引用中拿到对象,被虚引用引用的对象就跟不存在一样。虚引用一般用来跟踪垃圾回收情况,或者可以完成垃圾收集器之外的一些定制化操作。
二、多线程基础
1 线程状态
线程通常有五种状态:创建,就绪,运行,阻塞和死亡状态。
阻塞的情况分为三种:
- 等待阻塞:运行的线程执行
wait
方法,该线程会释放占用的所有资源,JVM会把该线程放入“等待池”中。进入这个状态后,不能自动唤醒,必须依靠其他线程调用notify
或notifyAll
方法才能被唤醒,wait
是object
类的方法 - 同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,JVM会把该线程放入“锁池”中
- 其他阻塞:运行的线程执行
sleep
或join
方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep
状态超时、join
等待线程终止或者超时,线程重新转入就绪状态。
Java中的线程状态:
- 初始(NEW):新创建了一个线程对象,但还没有调用start()方法。
- 运行(RUNNABLE):Java线程中将就绪(ready)和运行中(running)两种状态笼统的称为“运行”。
线程对象创建后,其他线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取CPU的使用权,此时处于就绪状态(ready)。就绪状态的线程在获得CPU时间片后变为运行中状态(running)。- 阻塞(BLOCKED):表示线程阻塞于锁。
- 等待(WAITING):进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)。
- 超时等待(TIMED_WAITING):该状态不同于WAITING,它可以在指定的时间后自行返回。
- 终止(TERMINATED):表示该线程已经执行完毕。
注意:调用start方法,该线程变成可运行状态,等待CPU分配,而不是直接运行。
Java线程状态切换(网图):
2 sleep()、wait()、join()、yield()的区别
- 锁池
所有需要竞争同步锁的线程都会放到锁池当中,比如当前对象的锁已经被其中一个线程得到,则其他线程需要在这个锁池进行等待,等前面的线程释放同步锁后锁池中的线程去竞争同步锁,当某个线程得到后会进入就绪队列等待CPU资源分配。
- 等待池
当调用wait()
方法后,线程会放到等待池当中,等待池中的线程不会去竞争同步锁,只有调用了notify()
或notifyAll()
后等待池的线程才会开始去竞争锁,notify()
是随机从等待池选出一个线程放入锁池,notifyAll()
是将等待池所有线程放到锁池。
sleep()
和wait()
的区别
-
sleep
是Thread
类的静态本地方法,wait
是Object
类的本地方法 -
sleep
方法不会释放锁,但wait
会释放,而且会加入等待队列中 -
sleep
不依赖于synchronized
,但wait
需要与synchronized
配套使用 -
sleep
不需要被唤醒,而wait
需要 -
sleep
一般用于当前线程休眠,或者轮询暂停,wait
则用于多线程通信
yield()
方法
执行后线程直接进入就绪状态,马上释放了CPU的执行权,但保留了CPU的执行资格,所以有可能CPU下次线程调度·还会让这个线程获取到执行权继续执行
join()
方法
join()
执行后线程进入阻塞状态,例如在线程B中调用线程A的join()
,那线程B会进入到阻塞队列,直到线程A结束或中断线程(即线程A插队)
public static void main(String[] args)throws InterruptedException {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.pringln("t1->run");
}
});
t1.start();
t1.join();
System.out.println("main->run");
}
运行结果:
t1->run
main->run
3 停止线程的方法stop()、interrupt()、interrupted()有什么区别
stop()
方法
stop()
方法为暴力停止线程,会立即释放线程所有资源并终止线程,会导致例如,锁异常释放、下载中断产生内存碎片等不良后果,目前Java已废除该方法,根据源码,调用会抛出UnsupportedOperationException
异常。
interrupt()
方法
interrupt()
会为线程打上一个中断标记,但不会立刻中断线程。其他情况:
- 线程处于
sleep
、wait
、join
状态下,调用此方法会清除中断标记,并抛出InterruptedException
异常 - 在线程处于IO方法阻塞时被调用(
java.nio.channels.InterruptibleChannel
)
通道将被关闭,将会抛出ClosedByInterruptException
异常并设置中断状态为true
- 在线程处于选择器中被调用(
java.nio.channels.Selector
)
中断状态将被设置为true
,线程立即从选择操作中返回,可能有一个非0值,就像选择器的(java.nio.channels.Selector#wakeup
)方法被调用一样(没用个这个玩意,这句注释没看懂)
interrupted()
方法
静态方法,测试当前线程是否被设置了中断标记,并清除标记。
isInterrupted()
方法
测试线程是否被设置了中断标记,不清除此标记。
4 关于线程安全的理解
线程安全,其实是内存安全,堆是共享内存,可以被所有线程访问。
当多个线程访问一个对象时,如果不用进行额外的同步控制或其他的协调操作,调用这个对象的行为都可以获得正确的结果,我们就说这个对象是线程安全的。
堆是进程和线程共有的空间,分全局堆和局部堆。全局堆就是所有没有分配的空间,局部堆就是用户分配的空间。堆在操作系统对进程初始化的时候分配,运行过程中也可以向系统要额外的堆,但是用完了需要归还给操作系统,否则就是发生了内存泄漏。
在Java中,堆是虚拟机所管理的内存中最大的一块,是所有线程共享的一块内存区域,在虚拟机启动时创建。堆所在的内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
栈是每个线程独有的,保存其运行状态和局部变量。栈在线程开始的时候初始化,每个线程的栈互相独立,因此,栈是线程安全的。操作系统在切换线程的时候会自动切换栈。栈空间不需要在高级语言里显式分配和释放。
5 守护线程
守护线程守护整个JVM中所有用户线程,依赖整个进程运行,如果其他线程全部结束,守护线程会被中断。
GC垃圾回收线程就是一个经典的守护线程,当垃圾回收线程是JVM上仅剩的线程时,垃圾回收线程会自动离开。它始终在低级别的状态中运行,用于实时监控和管理系统中的可回收资源。
守护线程中产生的新线程也是守护线程。
Java自带的多线程框架,会将守护线程转换为用户线程,所以如果要使用后台线程就不能用Java线程池。
6 ThreadLocal原理
ThreadLocal
ThreadLocal
为线程本地变量。
Thread
类中有一个成员变量threadLocals
ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocalMap
是ThreadLocal
的内部类,将ThreadLocal
对象以弱引用存储在key
中,结构如下:
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
}
set()
方法会将当前线程的ThreadLocalMap
对象取出,以当前ThreadLocal
对象为key
存储进ThreadLocalMap
对象中
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
使用ThreadLocal
可以打破多层次传递约束,减少代码冗余。例如在View.java
中,setBackgroundDrawable
方法就会将padding
值存储在threadLocal
中,方便随时读取。而且每一个线程都存储了ThreadLocalMap
对象,无需使用同步机制就实现了线程间数据隔离。
ThreadLocal
内存泄漏
如果一个ThreadLocal
不存在外部强引用,key
就会被GC回收,这样就会导致ThreadLocalMap
中的key
为null
,而value
还存在线程变量的引用。只有线程结束,强引用链才会断掉,如果线程不结束,key
为null
的value
就会一直存在强引用。
为什么ThreadLocalMap的key不使用强引用
因为如果使用强引用,ThreadLocalMap
还持有ThreadLocal
的强引用,如果没有手动删除就不会被回收,导致Entry
内存泄漏。
解决办法
- 每次使用完
ThreadLocal
都调用它的remove()
方法清除数据 - 将
ThreadLocal
变量定义成private static
,这样就一直存在ThreadLocal
的强引用,也就能保证任何时候都能通过ThreadLocal
的弱引用访问到value
值,进而清除掉。
7 线程池及相关参数
使用线程池,可以降低资源消耗,提高响应速度。它提高了线程利用率,降低了创建和销毁线程的消耗。
corePoolSize
:代表核心线程数。这些线程创建后并不会消除,而是常驻线程maximumPoolSize
:代表最大线程数。它与核心线程数对应,表示最大允许被创建的线程数,当核心线程数用完还无法满足需求时,会创建新的线程,但线程池内线程总数不会超过最大线程数keepAliveTime
、unit
:表示超出核心线程数之外线程的空闲存活时间,unit
为时间单位。可以通过setKeepAliveTime
来设置空闲时间workQueue
:用来存放待执行的任务,当核心线程已被使用,还有任务进来则全部放入队列,直到队列被放满但任务还持续进入,则会开始创建新的线程。Handler
:任务拒绝策略。线程池达到最大线程数,任务队列也满时,应用任务拒绝策略。ThreadFactory
:线程工厂,用来生产线程执行任务。我们可以选择使用默认的创建工厂,产生的线程都在同一个组内,拥有相同的优先级,且都不是守护线程。当然我们也可以选择自定义线程工厂,一般我们会根据业务来制定不同的线程工厂。
创建新线程时,需要获取全局锁,这时其它的就得阻塞,影响了整体效率,所以当核心线程数满了之后,任务来了先进阻塞队列。
几种常用线程池使用场景
Executor executor1 = Executors.newCachedThreadPool();
Executor executor2 = Executors.newSingleThreadPool();
Executor executor3 = Executors.newFixedThreadPool();
Executor executor4 = Executors.newScheduledThreadPool();
- 单线程线程池:用来进行单一任务的处理(如取消)
- 指定线程数的线程池:集中处理瞬时爆发的任务,如处理大量图片,使用后需要及时关闭
8 单例的实现原理
单例的基本实现思路是,每次调用单例对象时,先检查有没有初始化,如果没有才进行初始化,否则直接使用,基本实现如下:
class SingleMan {
private static SingleMan sInstance;
private SingleMan {
}
static SingleMan newInstance {
if (sInstance == null) {
sInstance = new SingleMan();
}
return sInstance;
}
}
此写法有明显线程同步的问题。A、B线程同时检查sInstance == null
后,A线程new
出新的对象,可能先返回使用,B线程再new
出新的对象,线程A返回使用的是一个无效对象。所以,会想到将方法加一个synchronized
:
class SingleMan {
private static SingleMan sInstance;
private SingleMan {
}
static synchronized SingleMan newInstance {
if (sInstance == null) {
sInstance = new SingleMan();
}
return sInstance;
}
}
但这样效率比较低,初始化成功后,每次取单例对象都要检查同步锁,所以考虑先检查单例是否初始化,如果没有初始化,再加上同步锁进行初始化:
class SingleMan {
private static SingleMan sInstance;
private SingleMan {
}
static SingleMan newInstance {
if (sInstance == null) {
synchronized (SingleMan.class) {
sInstance = new SingleMan();
}
}
return sInstance;
}
}
这种写法的问题是,当线程A、B同时检查到空时,线程A获得锁,创建对象,线程B等待,等待线程A创建完成后线程B获得锁重新创建一遍对象。于是,在同步锁中还需要检查一遍是否为空:
class SingleMan {
private static SingleMan sInstance;
private SingleMan {
}
static SingleMan newInstance {
if (sInstance == null) {
synchronized (SingleMan.class) {
if (sInstance == null) {
sInstance = new SingleMan();
}
}
}
return sInstance;
}
}
这种写法也会有问题。当初始化过程发生指令重排,进行了创建新对象,而构造方法没有完成时,对象在虚拟机中被标记为可用,此时另外一个线程检查空时会拿到不完整的的对象。因此,正确的单例写法应该是:
class SingleMan {
private static volatile SingleMan sInstance;
private SingleMan {
}
static SingleMan newInstance {
if (sInstance == null) {
synchronized (SingleMan.class) {
if (sInstance == null) {
sInstance = new SingleMan();
}
}
}
return sInstance;
}
}