1、== 和 equals的区别
== 和 equals的区别
2、String、StringBuffer与StringBuilder之间区别
String、StringBuffer与StringBuilder之间区别
3、List和Set的区别
List:有序,按添加的顺序来保存数据;数据可以重复。可以使用Iterator进行遍历数组元素;还可通过get(i)方法进行遍历
Set:无序,数据不可重复;只能使用Iterator进行遍历数组元素
面试题:谈谈你对面向对象的理解?
对比面向过程,面向对象是两种不同的处理问题的角度。
面向过程更注重事情的每一个步骤及顺序,面向对象更注重事情有哪些参与者(对象),及各自需要做些什么
举例说明:洗衣机洗衣服
面向过程:会将任务拆解成一系列的步骤(函数),1、打开洗衣机—》2、放衣服—》3、放洗衣粉—》4、清洗—》5、烘干
面向对象:会拆分出人和洗衣机两个对象。
人:1、打开洗衣机—》2、放衣服—》3、放洗衣粉
洗衣机:1、清洗—》2、烘干
面向对象三大基本特性:封装、继承、多态
谈谈你对面向对象的理解?
面向对象的简单理解
封装(encapsulation)有时也称数据隐藏,是处理对象的一个重要概念。
从表面看,封装就是把数据和行为组合到一个类中,并对外只暴露它的功能,但隐藏具体的实现方式。相当于给调用类的对象赋予了“黑盒子”特征。
在类中:数据称之为属性,操作数据的过程称为方法。作为类的一个实例,特定对象都有特定的属性。这些集合就是当前对象的状态(state)。无论何时,只要在对象上调用一个方法,它的状态就可能发生改变。
实现封装的关键在于,绝对不能让类中的方法直接访问其它类的数据。程序只能通过对象方法和对象数据进行交互。封装给予对象“黑盒子”的特征,是提高重用性和安全性的关键。在类里面完全可以改变数据存储方式,例如给数据加上static修饰,数据存储的位置就会移到元空间,但只要使用同样的方法操作数据,对象就不会知道也不用关心这个类发生的变化。
举例:使用洗衣机时,我们不用关心洗衣机的内部结构及实现原理,只需要通过它提供的按钮来操作。
类的另一个特性能让Java看起来更简单,就是可以通过扩展其他类来构建新类,这称之为继承(inheritance)。在扩展一个类时,扩展出的新类继承被扩展的类的全部属性和方法。你只需要在新类中提供合适这个新类的新方法和新属性就够了。
事实上,在Java中所有的类,都继承于一个超级父类,它就是Object类。所有类都扩展自这个Object类。对于继承关系,被继承的称为父类,继承的称为子类。
类中有父类和子类之称,子类创建的对象,也是父类型的一种形态。这就是对象的多态性。
扩展:方法的重载和重写也是多态的一种,不过是方法的多态。可以理解为相同方法名称的多种形态。
多态在编程实操中体现在对对象的转型。由于对象存在多态性,所以可以进行转型操作。
而转型分为两种:
1、向上转型:将子类对象变为父类对象
java语法:Animal animal = new Dog();
注意:子类(范围小)可以传递给父类(范围大)。但是父类传递给子类则不行,否则会溢出,除非在传的时候进行范围限定。
2、向下转型:将父类对象传给子类对象
java语法:Dog dog= (Dog) animal;
Java:多态的概念和案例实现
重写是子类对父类的允许访问的方法的实现过程进行重新编写, 返回值和形参都不能改变。即外壳不变,核心重写!
重写是发生在类的继承关系,或者类的实现关系中的,重写后的方法和原方法需要保持完全相同的返回值类型、方法名、参数个数以及参数类型,简单来说,就是子类重写的父类的方法必须和父类的方法保持完全一致。返回值范围小于等于父类,抛出的异常范围小于等于父类,访问修饰符范围大于等于父类。若父类方法的访问修饰符为private,则子类不能重新该方法。
重载(overloading) 是在一个类里面,方法名字相同、参数类型不同。返回类型和权限修饰符可以相同也可以不同,发生在编译时
// 如下代码,编译会错误
public int add(int a,int b){
return 0;
}
public String add(int a,int b){
return "0";
}
每个重载的方法(或者构造函数)都必须有一个独一无二的参数类型列表。
最常用的地方就是构造器的重载,比如在ThreadPoolExecutor线程池的实现类中,可看到如下的重载方法。
重载和重写的区别
面向对象的详解,只看这一篇就够了
接口的设计目的是对类的行为进行约束(更准确的说是一种"有"约束,因为接口不能规定类不可以有什么行为),也就是提供一种机制,可以强制要求不同的类具有相同的行为。它只约束了行为的有无,但不对如何实现行为进行限制。
抽象类的设计目的是代码复用。当不同的类具有某些相同的行为(记为行为集合A),且其中一部分行为的实现方式一致时(A的非真子集,记为B),可以让这些类都派生于一个抽象类。在这个抽象类中实现了B,避免让所有的子类来实现B,这就达到了代码复用的目的。而A减B的部分,留给各个子类自己实现。正是因为A-B在这里没有实现,所以抽象类不允许实例化出来(否则当调用到A-B时,无法执行)。
抽象类是对类本质的抽象,表达的是is a的关系,比如:BMw is a car。抽象类包含并实现子类的通用特性,将子类存在差异化的特性进行抽象,交由子类去实现。
而接口是对行为的抽象,表达的是like a的关系。比如: Bird like a Aircraft(像飞行器一样可以飞),但其本质上is a Bird。接口的核心是定义行为,即实现类可以做什么,至于实现类主体是谁、是如何实现的,接口并不关心。
使用场景:当你关注一个事物的本质的时候,用抽象类;当你关注一个操作的时候,用接口。
抽象类的功能要远超过接口,但是,定义抽象类的代价高。因为高级语言来说(从实际设计上来说也是)每个类只能继承一个类。在这个类中,你必须继承或编写出其所有子类的所有共性。虽然接口在功能上会弱化许多,但是它只是针对一个动作的描述。而且你可以在一个类中同时实现多个接口。在设计阶段会降低难度
时间复杂度
时间复杂度是用来来评估代码的执行耗时的,大O表示法:不具体表示代码的真正执行时间,而是表示代码执行时间随数据规模增长的变化趋势。
当n很大时,低阶、常量、系数并不能影响其增长趋势,因此可以忽略
list底层是数组实现,数组是一种连续内存空间存储相同数据类型数据的线性结构。
在根据数组索引获取元素的时候,会用索引和寻址公式来计算内存所对应的元素数据,寻址公式是**:数组的首地址+索引乘以存储数据的类型大小**
a[i] = baseAddress + i * dataTypeSize
1、随机查询(根据索引查询)
数组元素的访问是通过索引来访问的,计算机通过数组的首地址和寻址公式能够很快速的找到想要访问的元素
随机(通过下标)查询的时间复杂度是O(1)
2、未知索引查询
查找元素(未知下标)的时间复杂度是O(n)
查找元素(未知下标但排序)通过二分查找的时间复杂度是O(logn)
插入和删除的时间复杂度
插入和删除时,为了保证数组的内存连续性,需要移动数组元素,最好的情况为O(1),最坏的情况为O(n),平均时间复杂度为O(n)
ArrayList的扩容机制
ArrayList底层是基于动态数组实现的
1、ArrayList的初始容量为0,当第一次添加数据时才会初始化容量为10
2、ArrayList在进行扩容时会扩容到原来容量的1.5倍,每次扩容时需要拷贝数组
3、ArrayList在添加数据时
ArrayList的扩容机制
ArrayList源码分析
面试题
List list = new ArrayList<>(10);扩容了几次
ArrayList构造方法
public ArrayList(int initialCapacity) {
// 若指定大于0的初始化容量,直接new出对象数组
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
// 若指定为0的初始化容量,那么elementData属性为空
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity);
}
}
答案:没有扩容
如何实现数组和List之间的转换
数组转List,调用Array.asList(arr)方法
List转数组,调用list.toArray(new DataType(list.size())) 返回该对象数组
调用list.toArray() 返回Object数组
用Arrays.asList转List后,如果修改了数组内容,list受影响吗
asList方法
@SafeVarargs
@SuppressWarnings("varargs")
public static <T> List<T> asList(T... a) {
return new ArrayList<>(a);
}
/**
* @serial include
*/
private static class ArrayList<E> extends AbstractList<E>
implements RandomAccess, java.io.Serializable
{
private static final long serialVersionUID = -2764017481108945198L;
private final E[] a;
ArrayList(E[] array) {
a = Objects.requireNonNull(array);
}
....
}
答案:受影响
分析:Arrays.asList转换list之后,如果修改了数组的内容,list会受影响,因为它的底层使用的Arrays类中的一个内部类ArrayList来构造的集合,在这个集合的构造器中,把我们传入的这个集合进行了包装而已,最终指向的都是同一个内存地址
List用toArray转数组后,如果修改了List内容,数组受影响吗
toArray方法
public <T> T[] toArray(T[] a) {
if (a.length < size)
// Make a new array of a's runtime type, but my contents:
return (T[]) Arrays.copyOf(elementData, size, a.getClass());
System.arraycopy(elementData, 0, a, 0, size);
if (a.length > size)
a[size] = null;
return a;
}
答案:不受影响
分析:list用了toArray转数组后,如果修改了list内容,数组不会影响,当调用了toArray以后,在底层是它是进行了数组的拷贝,跟原来的元素就没啥关系了,所以即使list修改了以后,数组也不受影响
单向链表
单向链表∶每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。记录下个结点地址的指针叫作后继指针next
双向链表
高频面试题:ArrayList和LinkedList的区别
总结如下:
二叉树,顾名思义,每个节点最多有两个“叉”,也就是两个子节点,分别是左子节点和右子节点。不过,二叉树并不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点。
二叉树每个节点的左子树和右子树也分别满足二叉树的定义。
常见的二叉树:
二叉搜索树
二叉搜索树(Binary Search Tree,BST)又名二叉查找树,有序二叉树或者排序二叉树,是二叉树中比较常用的一种类型二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值
红黑树
红黑树(Red Black Tree):也是一种自平衡的二叉搜索树(BST),之前叫做平衡二叉B树(Symmetric Binary B-Tree)
红黑树性质:
红黑树原理
散列(Hash)表
HashMap底层使用hash表数据结构,即数组、链表(jdk1.7及之前)或红黑树(jdk1.8)
1、当往HashMap中put元素时,利用key的hashCode重新hash计算出当前对象的元素在数组中的下标
2、存储时,若出现hash值相同的key,此时有两种情况:
若key相同,覆盖旧值
若key不同,则将当前的k-v值放入链表或红黑树中
3、当调用get方法时,直接找到hash值对应的下标,再判断key是否相同,从而找到对应的value。
面试题:HashMap在jdk1.7和jdk1.8中有什么区别
在JDK1.6,JDK1.7中,HashMap采用位桶(数组)+链表实现,即使用链表处理冲突,同一hash值的键值对会被放在同一个位桶里,当桶中元素较多时,通过key值查找的效率较低。
而JDK1.8中,HashMap采用位桶+链表+红黑树实现,当链表长度超过阈值(默认为8)且数组长度超过64时,将链表转换为红黑树,这样大大减少了查找时间。当扩容resize()方法时,红黑树拆分成的树节点数小于或等于临界值6时,红黑树将退化成链表。
HashMap的put方法执行流程
HashMap的put方法执行步骤
1、判断键值对数组table是否为空或为null,否则执行resize()进行扩容(初始化)
2、根据键值key计算hash值得到数组索引
3、判断table[i]==null,条件成立,直接新建节点添加
4、如果table[i]==null ,不成立
5、插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold(数组长度*0.75),如果超过,进行扩容。
HashMap扩容流程
面试回答:请说一下HashMap的扩容机制
1、在添加元素或初始化的时候需要调用resize方法进行扩容,第一次添加数据初始化数组长度为16,以后每次每次扩容都是达到了扩容阈值(数组长度*0.75)
2、每次扩容的时候,都是扩容之前容量的2倍;
3、扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中
代码
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// 扰动算法:key的hashCode和key的hashCode右移16位后进行异或操作
// 使hash值更加均匀的分布在数组的各个下标,减少hash冲突
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
....
// (n - 1) & hash :得到数组索引,使用&运算符取代%,效率更高
// 前提是数组长度必须是2的n次方
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
....
}
面试回答:
面试回答:
1、计算索引效率更高,如果数组长度是2的n次方 可以使用位与运算来取代取模(%)运算
2、当HashMap扩容时重新计算索引的效率更高,hash & oldCap = 0 的元素保留在原来的位置,否则要移到新的位置,新位置计算方式 = 旧位置 + oldCap
更多解析
HashMap底层实现原理概述
HashMap实现原理
HashMap实现原理分析
HashMap在jdk1.7下多线程死循环问题
参考回答
在jdk1.7的hashmap中在数组进行扩容的时候,因为链表是头插法,在进行数据迁移的过程中,有可能导致死循环
比如说,现在有两个线程
线程一:读取到当前的hashmap数据,数据中一个链表,在准备扩容时,线程二介入
线程二∶也读取hashmap,直接进行扩容。因为是头插法,链表的顺序会进行颠倒过来。比如原来的顺序是AB,扩容后的顺序是BA,线程二执行结束。
线程一:继续执行的时候就会出现死循环的问题。
线程一先将A移入新的链表,再将B插入到链头,由于另外一个线程的原因,B的next指向了A,所以B->A->B,形成循环。
JDK 8将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序),使用尾插法,就避免了jdk7中死循环的问题。
HashMap在jdk1.7下多线程死循环问题
ConCurrentHashMap是一种线程安全的HashMap
JDK1.8采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树
在JDK1.8中,放弃了Segment臃肿的设计,数据结构跟HashMap的数据结构是一样的:数组+红黑树+链表,采用CAS + synchronized来保证并发安全
本质区别:进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位。
包含关系:一个进程至少有一个线程,线程是进程的一部分。
资源开销:每个进程都有独立的地址空间,进程之间的切换会有较大的开销;线程可以看做轻量级的进程,同一个进程内的线程共享进程的地址空间,每个线程都有自己独立的运行栈和程序计数器,线程之间切换的开销小。
影响关系:一个进程崩溃后,在保护模式下其他进程不会被影响,但是一个线程崩溃可能导致整个进程被操作系统杀掉,所以多进程要比多线程健壮。
更多详解:线程与进程,你真得理解了吗
并行和并发的区别
现在都是多核CPU,在多核CPU下
并行和并发的区别
创建线程的方式有哪些
start方法和run方法的区别
start方法用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次
run方法封装了要被线程执行的代码,可以被调用多次。
线程包括哪些状态,状态之间是如何变化的?
新建t1、t2、t3三个线程,如何保证线程顺序执行?
方法1:使用join方法,此方法作用是等待线程运行结束。调用此方法的线程会进入timed_waiting直到线程执行完成后,此线程再继续执行
如上述代码,在t2线程里调用t1的join方法,在t3线程里调用t2的join方法。
更多方法如下
线程顺序执行的8种方法,最后一种你用过吗?
notify方法和notifyAll方法的区别?
notify方法只会随机唤醒一个线程;notifyAll方法会唤醒所有wait中的线程
wait方法和sleep方法的区别?
共同点:wait() 、wait(long) 和sleep(long) 都可以让当前线程暂时放弃CPU的使用权,进入堵塞状态
不同点:
1、方法所属不同
2、醒来的时机不同
如何停止一个正在运行中的线程
有三种方式可以停止线程
3、锁特性不同 ☆
synchronized关键字
Synchronized作用:Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】
Monitor
意为监视器,由jvm提供,c++实现
Owner:存储当前线程获取锁的线程,owner里只能有一个线程
EntryList:关联没有抢到锁的线程,处于Blocked状态的线程
WaitList:关联了调用wait方法的线程,处于Waiting状态的线程
Monitor实现的锁属于重量级锁,你了解过锁升级吗?
Monitor实现的锁属于重量级锁,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。在JDK 1.6引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。
每个Java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级)之后,该对象头的MarkWord中就被设置指向Monitor 对象的指针。
轻量级锁
在很多的情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替的执行同步块中的代码。这种情况下,用重量级锁是没必要的。因此JVM引入了轻量级锁的概念。
如下场景,同一个线程重入,所以不存在锁竞争
轻量级加锁流程
1、在线程栈中创建一个Lock Record,将其obj字段指向锁对象。
2、通过CAS指令将Lock Record的地址存储在对象头的mark word中,如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。
3、如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分为null,起到了一个重入计数器的作用。
4、如果CAS修改失败,说明发生了竞争,需要升级为重量级锁。
解锁流程
1、遍历线程栈,找到所有obj字段等于当前锁对象的Lock Record。
2、如果Lock Record的Mark Word为null,代表这是一次重入,将obj设置为null后continue。
3、如果Lock Record的 Mark Word不为null,则利用CAS指令将对象头的mark word恢复成为无锁状态。如果失败则升级为重量级锁。
偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行CAS操作。
Java 6中引入了偏向锁来做进一步优化:只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程ID是自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归该线程所有。
一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令
对象的内存结构
在HotSpot虚拟机中,对象在内存中存储的布局可分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充。
MarkWord
总结
Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。
重量级锁
底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
轻量级锁
线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是cAs操作,保证原子性
偏向锁
一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令
一旦发生锁竞争,无论轻量级锁还是偏向锁都会升级成重量级锁
Java内存模型
JMM(Java Memory Model)Java内存模型,定义了共享内存中多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性。
JMM把内存分为两块,一块是私有线程的工作区域(工作内存),一块是所有线程的共享区域(主内存)
线程跟线程之间是相互隔离,线程跟线程交互需要通过主内存
CAS
CAS的全称是:Compare And Swap(比较再交换),它体现的一种乐观锁的思想,在无锁情况下保证线程操作共享数据的原子性。
在JUC ( java.util.concurrent )包下实现的很多类都用到了CAS操作
CAS图解
第一步
第二步
第三步
自旋:重新读取共享变量V,再比较旧的预期值A和内存值V
解析:
一个当前内存值V、旧的预期值A、即将更新的值B,当且仅当旧的预期值A和内存值V相同时,将内存值修改为B并返回true,否则什么都不做,并返回false。如果CAS操作失败,通过自旋的方式等待并再次尝试,直到成功
优缺点:
优点:没有加锁,因此线程不会堵塞,效率较高
缺点:如果竞争激烈,会不断自旋,效率会下降
CAS底层实现
CAS底层是调用Unsafe类中的方法,由操作系统提供的,c/c++实现
更多详解:
CAS思想
乐观锁和悲观锁的区别
请谈谈你对volatile的理解
一个共享变量(成员变量、静态变量)被volatile修饰后会有以下特性
解析:被volatile修饰的变量,那么可以防止即时编译器(JIT)对该变量进行优化
;让一个线程对共享变量的修改对另一个线程可见
解决方案
什么是AQS?
全称是 AbstractQueuedSynchronizer,即抽象队列同步器。它是构建锁或者其他同步组件的基础框架
AQS常用实现类
AQS与Synchronized的区别
Synchronized | AQS |
---|---|
关键字 c++实现 | java实现 |
悲观锁,自动释放锁 | 悲观锁,手动开启和释放 |
锁竞争激烈情况下都是重量级锁,性能差 | 锁竞争激烈情况下,提供了很多解决方案 |
AQS执行原理
AQS内部维护了一个先进先出的双向队列,队列中存储的排队的线程在AQS内部还有一个属性state,这个state就相当于是一个资源,默认是O(无锁状态),如果队列中的有一个线程修改成功了state为1,则当前线程就相等于获取了资源。在对state修改的时候使用cas操作时保证了多线程修改的情况下原子性
ReenTrantLock
ReenTrantLock为可重入锁,相对于synchronized有以下特点
ReentrantLock主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者的实现类似
构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。
死锁,是指多个线程同时被阻塞,其中一个或者全部线程都在等待某个资源,由于资源争夺而造成的一中僵局。若无外力推进,他们都将无法推进。由于无限期的阻塞,程序没有办法进行正常终止。
死锁产生的条件
检查死锁的方法
1、jps 和 jstack命令
2、jdk自带死锁诊断工具(java安装目录下的bin目录下)
1、jconsole.exe :用于对jvm的内存,线程,类的监控,是一个基于jmx的GUI性能监控工具
2、VisualVM.exe:故障处理工具,能够监控线程,内存情况,查看方法的CPU时间和内存中的对象,已被GC的对象,反向查看分配的堆栈
更多详解
死锁基本介绍
死锁的成因和对应的解决方案
面试题:导致并发程序出现问题的根本原因是什么(Java程序中怎么保证多线程的执行安全)
答案:悲观锁,
java并发编程的特性
ThreadPoolExecutor线程池的核心参数
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
RejectedExecutionHandler handler) {}
拒绝策略如下:
AbortPolicy:直接抛出异常,默认策略;
CallerRunsPolicy:用调用者所在的线程来执行任务;
DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
DiscardPolicy:直接丢弃任务;
更多详解:Java线程池系列–核心参数/大小设置/使用示例
workQueue -当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
1、ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO。
2、LinkedBlockingQueue:基于链表结构的有界阻塞队列,FIFO。
3、DelayedWorkQueue:是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的
4、SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。
LinkedBlockingQueue | ArrayBlockingQueue |
---|---|
默认无界(数组长度),支持有界 | 强制有界 |
底层是链表 | 底层是数组 |
是懒惰的,创建节点时添加数据 | 提前创建Node数组 |
入队时添加新Node | 提前创建Node |
两把锁(头尾) | 一把锁 |
如何确定核心线程数
IO密集型任务:如文件读写、DB读写、网络请求等;核心线程数大小设置为2n+1
CPU密集型任务:如计算型代码、bitmap转换、gson转换;核心线程数大小设置为n+1
线程池的种类
在java.util.concurrent.Executors类中提供了大量创建连接池的静态方法,常见就有四种1、创建使用固定线程数的线程池
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
核心线程数和最大线程数一样,没有救急线程
LinkedBlockingQueue队列的最大容量为Integer.MAX_VALUE
2、单线程线程池,它只有一个线程来执行任务,保证所有任务按照顺序执行(FIFO 先进先出)
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1, 1,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
核心线程数和最大线程数都是1
LinkedBlockingQueue队列的最大容量为Integer.MAX_VALUE
3、可缓存线程池
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
核心线程数为0
最大线程数是Integer.MAX_VALUE
SynchronousQueue:不存储元素的堵塞队列,每个插入操作必须等待一个移出操作
适用于任务数比较密集但每个任务执行时间较短的场景
4、延迟和周期执行的线程池
public ScheduledThreadPoolExecutor(int corePoolSize) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue());
}
public ScheduledThreadPoolExecutor(int corePoolSize,
ThreadFactory threadFactory) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue(), threadFactory);
}
public ScheduledThreadPoolExecutor(int corePoolSize,
RejectedExecutionHandler handler) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue(), handler);
}
public ScheduledThreadPoolExecutor(int corePoolSize,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,
new DelayedWorkQueue(), threadFactory, handler);
}
参考阿里开发手册《Java开发手册-嵩山版》
【强制】
线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
说明:Executors返回的线程池对象的弊端如下∶
1 ) FixedThreadPool和SingleThreadPool
允许的请求队列长度为Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM
2 ) CachedThreadPool
允许的创建线程数量为Integer.MAX_VALUE,可能会创建大量的线程,从而导致OOM.
Semaphore 信号量,是JUC包下的一个工具类,底层是AQS,我们可以通过其限制执行的线程数量使用场景。
通常用于那些资源有明确访问数量限制的场景,常用于限流。
Semaphore使用步骤
创建Semaphore对象,可以给一个值
semaphore.acquire()方法请求一个信号量,这时候的信号量个数-1 (一旦没有可使用的信号量,也即信号量个数变为负数时,再次请求的时候就会阻塞,直到其他线程释放了信号量)
semaphore.release()方法释放一个信号量,此时信号量个数+1
ThreadLocal是多线程中对于解决线程安全问题的一个操作类,它会为每个线程分配一个独立的线程副本从而解决了变量并发访问冲突问题(线程数据隔离),同时实现了线程内的资源共享。
案例:使用JDBC操作数据库时,会将每一个线程的Connection放入各自的ThreadLocal中,从而保证每个线程都在各自的Connection 上进行数据库的操作,避免A线程关闭了B线程的连接。
ThreadLocal原理
ThreadLocal本质是一个线程内部类,从而让多个线程只操作自己内部的值,从而实现线程间数据隔离
public void set(T value) {
// 获取当前线程对象
Thread t = Thread.currentThread();
// 根据当前线程对象获取ThreadLocalMap
ThreadLocalMap map = getMap(t);
// 若map不为空,那么map已经初始化过,然后保存数据
if (map != null)
map.set(this, value);
// 否则创建map并保存数据
else
createMap(t, value);
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
// 内部成员数组INITIAL_CAPACITY = 16
table = new Entry[INITIAL_CAPACITY];
// 位与运算,结果与取模相同,计算出存放数据的下标
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}
get方法源码解析
public T get() {
// 获取当前线程对象
Thread t = Thread.currentThread();
// 根据当前线程对象获取ThreadLocalMap
ThreadLocalMap map = getMap(t);
if (map != null) {
// 获取ThreadLocalMap对应的entry对象
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
// 获取entry中的value
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
private Entry getEntry(ThreadLocal<?> key) {
// 位与运算,结果与取模相同,计算出存放数据的下标
int i = key.threadLocalHashCode & (table.length - 1);
// 获取该位置上的entry
Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
return getEntryAfterMiss(key, i, e);
}
ThreadLocal内存泄露问题
Java对象中的四种引用类型:强引用、软引用、弱引用、虚引用
强引用:最为普通的引用方式,表示一个对象处于有用且必须的状态,如果一个对象具有强引用,则GC并不会回收它。即便堆中内存不足了,宁可出现OOM,也不会对其进行回收。new的方式
弱引用:表示一个对象处于可能有用且非必须的状态。在GC线程扫描内存区域时,一旦发现弱引用,就会回收到弱引用相关联的对象。对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收
// 强引用
User user = new User();
// 弱引用
WeakReference weakReference = new WeakReference(user);
每一个Thread维护一个ThreadLocalMap,在ThreadLocalMap中的Entry对象继承了WeakReference。其中key为使用弱引用的ThreadLocal实例,value为线程变量的副本
JVM有哪些部分组成,运行流程是什么?
什么是堆?
线程共享的区域:主要用来保存对象实例,数组等,当堆中没有内存空间可分配给实例,也无法再扩展时,则抛出OutOfMemoryError异常。
jdk8—JVM内存结构
年轻代被划分为三部分,Eden区和两个大小严格相同的Survivor区,根据JVM的策略,在经过几次垃圾收集后,任然存活于Survivor的对象将被移动到老年代区间。
老年代主要保存生命周期长的对象,一般是一些老的对象
老年代主要保存生命周期长的对象
元空间保存类的信息、静态变量、常量、编译后的代码
jdk7和jdk8—JVM内存结构的区别
java虚拟机栈
垃圾回收主要指的是堆内存;当栈帧弹出栈后,内存就会释放
问题2:栈内存分配的越大越好吗
不一定,默认的栈内存是1M
栈帧过大会导致线程数变少,例如,机器的总内存为512M,那么能活动的线程数有512个,若把栈内存修改为2M,那么能活动的栈帧将减半
问题3:方法内的局部变量是线程安全的吗
问题4:哪些情况会导致栈溢出
问题5:堆和栈的区别
栈内存一般会用来存储局部变量和方法调用(栈帧);而堆内存是用来存储对象和数组的。堆会被gc,栈不会
栈内存是线程私有的;堆内存是线程共享的
栈空间不足异常: java.lang.StackOverFlowError。
堆空间不足异常: java.lang.OutOfMemoryError。
方法区
方法区是各个线程共享的内存区域,主要存储类的信息、运行时常量池,虚拟机启动时创建,虚拟机关闭时释放;若方法区中的内存无法满足分配请求时,会抛出OutOfMemoryError: Metaspace。
什么是类加载器,有哪些?
JVM只会运行二进制文件,类加载器的作用就是将字节码文件加载到JVM中,从而让Java程序能够启动起来。
什么是双亲委派模型?
双亲委派模型:加载某一个类,先委托上一级的加载器进行加载,如果上级加载器也有上级,则会继续向上委托,如果该类委托上级没有被加载,子加载器尝试加载该类。
(向上查找,向下加载),通俗来说是啃老机制
JVM为什么采用双亲委派机制
类装载的执行流程
类从加载到虚拟机中开始,直到卸载为止,它的整个生命周期包括了︰加载、验证、准备、解析、初始化、使用和卸载这7个阶段。其中,验证、准备和解析这三个部分统称为连接(linking)
加载阶段
解析阶段: 把类中的符号引用转换为直接引用
比如:方法中调用了其他方法,方法名可以理解为符号引用,而直接引用就是使用指针直接指向方法。
初始化阶段:对类的静态变量、静态代码块执行初始化操作
使用阶段:JVM开始从入口方法开始执行用户的程序代码
面试回答(总结):说一下类的执行过程
对象什么时候被垃圾器回收?
简单一句就是:如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,则有可能会被垃圾回收器回收。
如果要定义什么是垃圾,有两种方式来确定,第一个是引用计数法
,第二个是可达性分析算法
引用计数法
一个对象被引用了一次,就在当前对象头上增加一次引用次数,若这个对象的引用次数为0,代表这个对象可以被回收。若对象间出现了循环引用,那引用计数就会失效。
可达性分析法(主流)
哪些对象可以作为GC root
JVM垃圾回收算法有哪些?
标记清除算法,是将垃圾回收分为2个阶段,分别是标记和清除。
1、根据可达性分析算法得出的垃圾进行标记
2、对这些标记为可回收的内容进行垃圾回收
优缺点同标记清除算法,解决了标记清除算法的碎片化的问题,同时,标记压缩算法多了一步,对象移动内存位置的步骤,其效率也有有一定的影响。适用于老年代回收器
优点:在垃圾对象多的情况下,效率较高;清理后,内存无碎片
缺点:分配两块内存空间,在同一时刻,只能使用一半,内存使用效率低
总结
标记清除算法:垃圾回收分为2个阶段,分别是标记和清除,效率高,有磁盘碎片,内存不连续
标记整理算法:标记清除算法一样,将存活对象都向内存另一端移动,然后清理边界以外的垃圾,无碎片,对象需要移动,效率低
复制算法:将原有的内存空间一分为二,每次只用其中的一块,正在使用的对象复制到另一个内存空间中,然后将该内存空间清空,交换两个内存的角色,完成垃圾的回收;无碎片,内存使用率低
JVM的分代回收
分代收集算法
JVM有哪些垃圾回收器
串行回收器
本文编写来源b站java面试八股文