AQS源码核心、ThreadLocal以及强软弱虚引用

一.经典面试题

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不到。

结语

以上是我个人学习的一点小收获,有瑕疵的地方希望大家在评论区多多指点,共同进步,谢谢!
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值