一.经典面试题
1.1面试题1
面试题:实现一个容器,提供两个方法:add、size,写两个线程,线程1添加10个元素到容器中,线程2实现监控元素的个数,当个数到5个时,线程2给出提示并结束。
示例一(错误的写法):
public class Interview1 {
List list = new ArrayList();
void add(Object o){list.add(o);}
public int size(){return list.size();}
public static void main(String[] args) {
Interview1 interview1 = new Interview1();
new Thread(()->{
for (int i = 0; i < 10; i++) {
interview1.add(new Object());
System.out.println("add "+i);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"t1").start();
new Thread(()->{
while (true){
if(interview1.size()==5){
break;
}
}
System.out.println("t2 结束");
},"t2").start();
}
}
通过打印结果显示,这种方式是行不通的,而且程序add到9的时候是不停的。原因有:1)没有同步。ArrayList的size方法是当加入元素之后才更新集合长度的(进行的是++操作,而此操作不是线程安全的),如果没有加同步的话,有可能加到5的时候,还没来得及更新,就读取到了size,此时读到的add的长度和size的值是不一致的;2)线程之间不可见。由于t1线程和t2线程之间不可见,线程2读不到值,所以会一直while(true)下去。
解决方式:使用同步容器来解决这两个问题(下面会有同步容器的说明)。
示例二:通过wait、notify来实现
public class InterviewSolution2 {
List list = new ArrayList();
void add(Object o) {list.add(o);}
public int size() {return list.size();}
public static void main(String[] args) {
InterviewSolution2 interview2 = new InterviewSolution2();
Object lock = new Object();
new Thread(()->{
synchronized (lock){
System.out.println("t2 启动");
if(interview2.size()!=5){//判断:如果容器大小不等于5,就一直等待
try {
lock.wait();//知识点:线程通过wait阻塞之后,要继续执行,必须先拿到锁
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("t2 结束");
//通知t1继续执行,如果没有这步操作,t1线程就不会继续往下执行
lock.notify();
}
},"t2").start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
new Thread(()->{
System.out.println("t1 启动");
synchronized (lock){
for (int i = 0; i < 10; i++) {
interview2.add(new Object());
System.out.println("add "+i);
if(interview2.size()==5){
lock.notify();//notify方法并不会释放锁
try {
lock.wait();//wait方法会释放锁,释放锁让t2线程执行
} catch (InterruptedException e) {
e.printStackTrace();
}
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("t1 结束");
}
},"t1").start();
}
}
示例三:使用CountDownLatch实现
public class InterviewSolution3 {
List list = new ArrayList();
public void add(Object o){ list.add(o);}
public int size(){ return list.size();}
public static void main(String[] args) {
InterviewSolution3 interview = new InterviewSolution3();
CountDownLatch latch1 = new CountDownLatch(1);
CountDownLatch latch2 = new CountDownLatch(1);
new Thread(()->{
System.out.println("t2 启动");
if(interview.size()!=5){
try {
latch2.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("t2 结束");
latch1.countDown();
},"t2").start();
new Thread(()->{
System.out.println("t1 启动");
for (int i = 0; i < 10; i++) {
interview.add(new Object());
System.out.println("add "+i);
if(interview.size()==5){
latch2.countDown();
try {
latch1.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
/*try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}*/
}
},"t1").start();
}
}
代码逻辑说明:
关键在于,将 t1 线程sleep一秒的操作注释掉之后,会看到"t2 结束"没有在"add 4"之后输出,这是因为:在只有一个门闩且门闩上数为1的情况下,当进行latch.countDown()操作(唤醒t2线程执行)之后,t1线程会继续往下执行,由于执行速度太快,此时可能就会出现:当t1执行到"add 5"或者之后,才会输出"t2 结束"。要解决这个问题,需要再加一个门闩,当容器的大小为5的时候,通知t2线程执行(通过latch2.countDown方法),然后在 t1上闩上门闩(通过latch1.await方法);由于t2线程开始时就是等待状态,此时 t1 放开门闩后,t2 线程会执行,当t2执行完之后,再将锁在 t1 上的门闩放开(通过latch1.countDown方法),让 t1 继续执行。
1.2面试题2
实现一个固定容量同步容器,用有put和get方法,以及getCont方法。能够支持2个生产者线程以及10个消费者线程的阻塞调用。
示例1:
public class InterviewTwoSolution1<T> {
private final LinkedList<T> lists = new LinkedList<>();
private final int MAX = 10;
private int count = 0;
public synchronized void put(T t){
while (lists.size()==MAX){
try {
this.wait();//如果达到容器最大容量,线程阻塞
} catch (InterruptedException e) {
e.printStackTrace();
}
}
lists.add(t);
count++;
this.notifyAll();//唤醒等待队列中的线程
}
public synchronized T get(){
T t = null;
while (lists.size()==0){
try {
this.wait();//如果消费者取到0,线程阻塞
} catch (InterruptedException e) {
e.printStackTrace();
}
}
t = lists.removeFirst();
count--;
this.notifyAll();//唤醒等待队列中的线程
return t;
}
public static void main(String[] args) {
InterviewTwoSolution1<String> it = new InterviewTwoSolution1<>();
//启动消费者线程
for (int i = 0; i < 10; i++) {
new Thread(()->{
for (int j = 0; j < 5; j++) System.out.println(it.get());
},"c"+i).start();
}
//启动生产者线程
for (int i = 0; i < 2; i++) {
new Thread(()->{
for (int j = 0; j < 25; j++) it.put(Thread.currentThread().getName()+" "+j);
},"p"+i).start();
}
}
}
思考:为什么put和get方法中不用 if 判断,而要用while判断?
分析:如果用 if 判断,当lists的size达到最大值情况下,在当前线程被notifyAll(唤醒)之后,不会再判断lists的size是否为最大值,而是继续往下执行。此时,如果有别的线程已经往达到最大容量的lists里面put了,而当前线程被唤醒之后,由于没有重新判断lists的size,所以就会在达到最大容量时还继续往下执行add和count++操作,这时会出现虽然容器已经达到了最大容量,但是还是添加和count++的问题。所以必须用while,线程唤醒之后得重新判断size的大小。get方法同理。
瑕疵:
notifyAll方法:该方法会唤醒所有等待队列中的线程,而生产者和消费者线程都在等待队列中,一旦notifyAll之后,这些生产者和消费者线程就开始抢占锁,生产者线程wait(达到最大容量)之后,是没有必要叫醒其他生产者线程的,因为其他生产者线程来了也是在wait状态,从这个角度优化,可不可以只叫醒消费者而不叫醒生产者呢?此时有了如下示例:
public class InterviewTwoSolution2<T> {
private final LinkedList<T> lists = new LinkedList<>();
private final int MAX = 10;
private int count = 0;
Lock lock = new ReentrantLock();
private Condition producer = lock.newCondition();
private Condition consumer = lock.newCondition();
//生产者生产
public void put(T t){
try {
lock.lock();
while (lists.size()==MAX){
producer.await();
}
lists.add(t);
count++;
consumer.signalAll();//通知消费者消费
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
lock.unlock();
}
}
//消费者消费
public T get(){
T t = null;
try {
lock.lock();
while (lists.size()==0){
consumer.await();
}
t = lists.removeFirst();
count--;
producer.signalAll();//通知生产者生产
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
lock.unlock();
}
return t;
}
public static void main(String[] args) {
InterviewTwoSolution2<String> it = new InterviewTwoSolution2<>();
//启动消费者线程
for (int i = 0; i < 10; i++) {
new Thread(()->{
for (int j = 0; j < 5; j++) System.out.println(it.get());
},"c"+i).start();
}
//启动生产者线程
for (int i = 0; i < 2; i++) {
new Thread(()->{
for (int j = 0; j < 25; j++) it.put(Thread.currentThread().getName()+" "+j);
},"p"+i).start();
}
}
}
Condition介绍:
由示例1和示例2可以看出,ReentrantLock和Synchronized区别:ReentrantLock可以有两种condition(条件),从而可以精确的指定哪些线程被叫醒。synchronized中wait/notify时,只有一个等待队列;但是newCondition之后,会有多个等待队列,Condition的本质就是不同的等待队列,newCondition几次就有几个等待队列。
二.AQS—所有锁的核心
AQS的核心:成员变量:volatile int state;保证线程之间可见。state的数值具体是什么是由子类来决定的,如果是ReentrantLock的话,通过Sync类中nonfairTryAcquire方法内部的getState(此方法是调用了AQS的getState方法)来取得state,state记录线程重入的次数,如state值就为1,说明当前线程获得锁;如果state值为0,说明没有线程上锁,当前线程上锁的方式是CAS操作(重点!);如果state值从1变成2,说明加了第二次锁(表示线程重入了一次)。state值跟着一个AQS维护的一个队列,此队列中都是一个个的node(节点),是AQS中一个静态内部类,node类中重要的成员变量为:Thread,说明这个node节点中是一个个的线程,由于这个node可以指向前一个节点,也可以指向后一个节点,所以其是一个双向链表。
结论:AQS的核心是一个共享的数据state和队列中抢夺数据的线程,就是:state成员变量和监控state的双向链表线程节点。
下面根据debug运行下面的小程序,来从lock方法引入AQS核心:
说明:1)要初探AQS的神秘面纱,先将相关程序运行起来,再通过debug的方式去逐步跟进,如果你只是单纯的点进lock源码中去,会发现 跟到AQS中的tryAcquire方法时,会有惊喜出现,跟不下去了,因为在AQS中的tryAcquire方法只是抛出了一个异常,而实际中肯定是调用了其子类的重写方法(多态的体现)。
2)以下lock方法的UML图是根据JDK1.8之后的版本总结的(具体哪个版本我也不四很清楚),1.8版本中lock方法与此流程有些出入。
3)了解AQS的核心就认准其state变量和state后面跟着的队列,因为其state的值是根据子类来决定的,如:CountDownLatch、Semaphore、CyclicBarrier等,只要了解不同子类的具体实现,此时就有点豁然开朗的意思流。
感兴趣的小伙伴可以亲测一下,捎带脚了解一下unlock方法的调用流程。
三.ThreadLocal
如果你了解Spring或者数据库的声明式事务的话,你对ThreadLocal应该不陌生。例如:在数据库中,可以将数据库连接写在配置文件中,此时有好多方法要去配置文件中获取数据库连接,声明式事务可以将这些方法合到一起视为一个完整的事务,如果每个方法拿到连接不是同一个,那这就不能形成一个完整事务,那么如何保证不同的方法拿到的是同一个连接呢?解决方案:将连接放到本地线程ThreadLocal中,第一个方法拿到连接后,将连接放到本地线程中,之后的方法再获取连接的时候去本地线程中获取,不从线程池中取。
下面用简单的Demo来对ThreadLocal来个粗浅的入门了解:
public class ThreadLocal02 {
static ThreadLocal<Person1> p = new ThreadLocal<>();
public static void main(String[] args) {
new Thread(()->{
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(p.get());//如果本地线程ThreadLocal中有值的话,会get到值
}).start();
new Thread(()->{
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
p.set(new Person1());//将新的Person1对象设入ThreadLocal中
}).start();
}
}
class Person1{
String name = "zhangsan";
}
运行之后,结局有点小意外,得到的值是:null,这是闹哪样呢?
玄机在set方法上,如果用ThreadLocal时,其set的值是线程独有的,就是只有自己的线程能访问到。现在我们跟进set方法的源码,一探其set值时的过程,之后你就会了然了。set方法源码如下:
public void set(T value) {
Thread t = Thread.currentThread();//获得当前线程
ThreadLocalMap map = getMap(t);//通过传入的当前线程对象得到当前线程的Map,意思就是每一个线程都有一个map容器
if (map != null)
map.set(this, value);//this是指当前ThreadLocal对象,value是指自己要设的那个值
else
createMap(t, value);
}
get方法同理,使用ThreadLocal的get和set方法时,是在当前线程里进行操作,与其他线程隔离开了,这就解释了上面小程序中一个线程set值之后,另一个线程get不到值的问题 了。
四.Java的四种引用
4.1 强引用
引用是指:变量指向new出来的对象,如:Object o = new Object()。普通的引用就是强引用,强引用的特点:当有引用指向对象时,垃圾回收器不会回收(java中是不需要手动进行垃圾回收的,C和C++是需要的),只有没有引用指向对象时,才会被回收。
4.2 软引用
软引用:当一个对象被软引用指向它的时候,只有在系统内存不够的时候才会回收,内存够的情况下是不会回收的。可以用作缓存。不过现在使用缓存的时候,一般用Redis。在run配置中设置VM Option参数:-Xms20M -Xmx20M,表示最大最小内存都为20M,一般生产环境下,最大最小内存都设置成一样的。请看如下代码:
public class Test02_SoftReference {
public static void main(String[] args) {
//栈内存中的m指向堆内存中的软引用对象,软引用里面指向一个10M大小的字节数组
SoftReference<byte[]> m = new SoftReference<>(new byte[1024*1024*10]);
System.out.println(m.get());
System.gc();
try {
Thread.sleep(300);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(m.get());
//再分配一个15M的字节数组,堆内存装不下,这时候系统会垃圾回收
byte[] b = new byte[1024*1024*15];
System.out.println(m.get());
}
}
这里有点小问题,第三次get的时候应该输出为null,但是我这直接抛出内存溢出的异常了,有没有大佬可以指点指点其中奥妙。
4.3 弱引用
弱引用:当一个对象被弱引用指向它的时候,只要垃圾回收,就会被清理。作用:如果弱引用指向一个对象,此时有另一个强引用指向该对象,只要强引用不再指向它时,该对象应该被自动回收。
public class Test03_WeakReference {
public static void main(String[] args) {
//w指向了一个弱引用对象,弱引用对象中指向了M对象
WeakReference<M> w = new WeakReference(new M());
System.out.println(w.get());
System.gc();
System.out.println(w.get());//一旦遭遇gc,弱引用对象就会被回收,此时get不到值的
}
}
class M{
@Override
protected void finalize() throws Throwable {
System.out.println("垃圾回收器工作了");//只要垃圾回收器工作,该方法就会被调用。实际中不会重写该方法。
}
}
弱引用应用场景:一般用在容器中,ThreadLocal中也有使用。下面根据下面小代码介绍弱引用在ThreadLocal中的应用。
public class Test03_WeakReference {
public static void main(String[] args) {
ThreadLocal<M> tl = new ThreadLocal<>();
tl.set(new M());
tl.remove();
}
}
class M{
@Override
protected void finalize() throws Throwable {
System.out.println("垃圾回收器工作了");//只要垃圾回收器工作,该方法就会被调用。实际中不会重写该方法。
}
}
下面就根据:tl.set(new M()); 代码,来说明为什么ThreadLocal对象是通过弱引用来指向M对象的。通过上述的ThreadLocal中set方法源码可知,set方法是将ThreadLocal对象和要设的值放到本地Map中的,那么再跟进Map的set方法:
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
//下面的代码先略过,重点在于Entry对象
...
}
接下来再一探Entry的究竟,会有如下发现,此时奥秘得以揭开:
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
这个Entry对象的父类竟然是WeakReference!而且在其构造方法中调用了super(k),k就是ThreadLocal对象,所以Entry对象中的key是通过一个弱引用指向ThreadLocal对象的,value就是 M 对象,于是有了以下关系图:
思考1:为什么Entry要使用弱引用?
分析:tl 是一个局部变量,当方法结束的时候,这个强引用也就消失了,如果此时ThreadLocal对象还被一个强引用key指向的时候,它是不会被回收的,这样很容易导致内存泄露(注意:不是内存溢出!内存泄露是有部分内存永远不会被回收),比如有些服务器线程是不间断运行的,但是ThreadLocalMap是永远存在的,如果Entry是一个强引用的话,那就永远不会被回收,就导致了内存泄露。如果是一个弱引用的话,当强引用消失的时候,只要gc就会消失。
思考2:当强引用消失,key也被回收了,此时key变成了空值null,那这个key指向的value还能被访问的到吗?
分析:key为空的记录是访问不到的,如果ThreadLocalMap越来越长,还是会有内存泄露的问题。所以,切记:使用ThreadLocal时,用完必须手动remove!!!
4.4 虚引用
主要作用:堆外内存的释放,虚引用一般是给写虚拟机的大牛用的,程序员用不着。虚引用(PhantomReference)的构造方法没有只含一个参数的。虚引用的作用只是给你一个通知,通知虚引用对象被回收了,通知的时候放到队列里,因为虚引用一旦被回收,虚引用会装到队列中,如果队列中有值,就说明某个虚引用被回收了。演示代码如下:
import java.lang.ref.PhantomReference;
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
import java.util.LinkedList;
import java.util.List;
public class Test04_PhantomReference {
private static final List<Object> LIST = new LinkedList<>();
private static final ReferenceQueue<M> QUEUE = new ReferenceQueue();
public static void main(String[] args) {
PhantomReference<M> phantomReference = new PhantomReference<>(new M(),QUEUE);
new Thread(()->{
while (true){
LIST.add(new byte[1024*1024]);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(phantomReference.get());
}
}).start();
new Thread(()->{
while (true){
Reference<? extends M> poll = QUEUE.poll();
if(poll != null){
System.out.println("虚引用对象被回收了 "+poll);
}
}
}).start();
}
class M{
@Override
protected void finalize() throws Throwable {
System.out.println("垃圾回收器工作了");//只要垃圾回收器工作,该方法就会被调用。实际中不会重写该方法。
}
}
你会发现虚引用对象get到的值都是null,虚引用和弱引用不同之处:虚引用get的值永远为null,而当弱引用对象有值时,是可以get到值的。
知识延伸:
写jvm的大佬用虚引用来干嘛呢?当队列中有值时,他们会做出相应的处理,那什么时候会做出处理呢?经常使用的一种情况为:NIO中有一个直接内存(DirectByteBuffer),直接内存是不被jvm虚拟机直接管理的内存,是被操作系统管理的,又称堆外内存,DirectByteBuffer是可以指向堆外内存的,比如Netty分配内存时就是用的堆外内存。假想DirectByteBuffer值为null,垃圾回收器能回收它吗?肯定不行啊,它都不在堆里,此时可以用虚引用回收堆外的内存,因为当对象被回收时,通过Queue可以检测到,然后清理堆外内存,java中提供了UnSafe类(JUC底层好多的CAS操作都用到它)来回收堆外内存。
说明:虚引用可以指向任何对象,不管堆外的还是jvm管理的,只不过get不到。
结语
以上是我个人学习的一点小收获,有瑕疵的地方希望大家在评论区多多指点,共同进步,谢谢!