【面试题】多线程面试题总结

最近在看面试题,所以想用自己的理解总结一下,便于加深印象。

为什么使用多线程

  1. 使用多线程可以充分利用CPU,提高CPU的使用率。
  2. 提高系统的运行效率,对于一些复杂或者耗时的功能,可以对其进行拆分,比如将某个任务拆分了A、B、C三个子任务,如果子任务之间没有依赖关系,那么就可以使用多线程同时运行A、B、C三个子任务,来提高效率。
  3. 可以通过多线程处理一些异步任务。

创线程的方式有哪些

(1)继承Thread方式创建

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("run task by Thread");
    }
}

启动线程:

public class Test {
    public static void main(String[] args) {
        // 创建线程
        MyThread myThread = new MyThread();
        // 启动线程
        myThread.start();
    }
}

(2)通过实现Runnable的方式创建

class Task implements Runnable{
    @Override
    public void run() {
         System.out.println("run task by Runnable");
    }
}

启动线程:

public class Test {
    public static void main(String[] args) {
         // 创建线程
        Thread myTask = new Thread(new Task());
         // 启动线程
        myTask.start();
    }
}

(3)如果需要返回值,还可以使用Callable+FutureTask的方式(也可以通过Callable+Future配合线程池使用):

class Task implements Callable<Integer>{

    /**
     * 相当于run方法
     */
    @Override
    public Integer call() throws Exception {
        Thread.sleep(5000);
        int sum = 0;
        for(int i=0; i<10; i++) {
            sum += i;
        }
        return sum;
    }
}

运行线程:

public class FutureTest {
    public static void main(String[] args) {
        // 创建FutureTask
        FutureTask<Integer> futureTask = new FutureTask<Integer>(new Task());
        // 创建线程
        Thread thread = new Thread(futureTask);
        // 启动线程
        thread.start();
        try {
            // get()方法可以获取返回结果,它会阻塞到任务执行完毕
            System.out.println("运行结果:" + futureTask.get());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }
    }
}

Runnable和Callable的区别
相同点

(1)创建线程通常使用实现接口的方式,面向接口编程,这样在开发过程中可以提高系统的扩展性和灵活性,Runnable和Callable都是接口;
(2)Runnable和Callable都需要通过Thread.start()启动线程;

不同点
(1)Runnable的run方法没有返回值只能返回void,并且不能向上抛出异常,而Callable的call方法既可以有返回值,也可以向上抛出异常。

如何解决线程安全问题

  1. 提供了syncronized关键字来加锁;
  2. 提供了Lock接口来加锁;
  3. volatile关键字,可以保证可见性,需要注意它保证的只是可见性,并不能保证原子性,如果是非原子性的操作,单纯使用volatile并不能保证线程的安全;
  4. java.util.concurrent包下提供了一系列类,比如一些并发容器ConcurrentHashMap等;

syncronized和Lock的区别

  1. syncronized是Java中的关键字,而Lock是一个接口;
  2. syncronized在发生异常时,可以自动释放线程持有的锁,Lock在发生异常时,如果没有通过unLock方法释放锁,则不会自动释放锁,所以在使用Lock的时候注意在finally中调用unLock去释放锁,否则有可能导致死锁的发生;
  3. Lock调用lockInterruptibly在等待抢占某个锁的过程中,可以响应中断,而synchronized不能,会一直等待下去,不能响应中断;
  4. Lock通过tryLock方法可以设置等待时间,在设定的时间内如果未能成功获取锁,将放弃抢占锁,并返回false,如果成功获取锁返回true,通过返回值可以判断是否获取到锁,而syncronized没有这个功能;

syncronized实现原理

syncronized可以加在方法上,对整个方法实现同步访问,也可以使用在代码块中,对某块代码进行同步访问。

(1)普通方法

对于普通方法,锁住的是当前实例对象:

public class SyncTest {

    private int i = 0;

    public synchronized void add() {
        System.out.println("【synchronized】");
        i++;
    }
}

在编译的字节码中,可以看到方法的flag中有ACC_SYNCHRONIZED标记:

(2)静态方法

对于静态方法,锁住的是当前类的class对象:

public class SyncTest {

    private static int j = 0;

    // 静态方法,锁住的是SyncTest.class
    public static synchronized void add() {
        System.out.println("【static synchronized】");
        j++;
    }
}

同样在编译的字节码中,可以看到ACC_SYNCHRONIZED标记:

(3)同步代码块

对于同步代码块,锁住的是括号中的对象:

public class Test {

    private int i = 0;

    public void add() {
        // 锁住的是括号内的对象,这里锁住的是Test.class
        synchronized (Test.class) {
            i++;
        }
    }
}

在编译后的字节码中,可以看到添加了monitorenter和monitorexit指令:

每个实例对象和类的Class对象都会关联一个Monitor,Monitor字面翻译为监视器,也可以称之为管程,在Java中就是通过它来实现多线程下的同步访问的。

对于同步代码块,在编译后的字节码中看到添加了monitorenter和monitorexit指令,当执行到monitorenter时,就会去获取锁住对象的Monitor锁,它可以保证在同一时刻只能有一个线程获取到锁,获取成功之后才可以往下进行,monitorexit指令执行时会释放掉Monitor锁。

对于同步方法,执行时会检查方法的flag中是否有ACC_SYNCHRONIZED标记,如果有同样会获取对应的Monitor锁,并在方法执行完毕之后释放锁。

JDK 1.6以后锁优化

在JDK 6之后,为了提高程序的运行效率,进行了一些锁优化技术,主要包括以下几个方面。

自适应自旋

当一个线程获取锁时,如果锁已经被其他线程获取,该线程通过不断循环的方式,一直尝试获取锁的过程称为自旋锁。
如果线程一直获取不到锁,频繁的自旋会增加资源的消耗,带来性能的浪费,在JDK 6以后对自旋锁进行了优化,引入了自适应自旋,虚拟机会自动调整自旋的时间,比如某一线程通过自旋成功获取锁并且在运行中,如果其他线程来竞争锁,虚拟机会认为这次自旋竞争锁也很有可能成功,所以会允许自旋的时间多一些,如果对于某个锁,线程很少自旋获取成功,那么虚拟机认为以后获取这个锁也会比较难,可能会直接省略自旋的过程,避免资源的浪费。

锁消除
对于一些同步代码,通过逃逸分析,如果能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么虚拟机会认为这个变量的读写不会存在竞争,对这个变量的同步措施就可以消除掉。

    public String concat(String s1, String s2) {
        StringBuffer buffer = new StringBuffer();
        // StringBuffer的append方法是同步方法
        buffer.append(s1);
        buffer.append(s2);
        return buffer.toString();
    }

StringBuffer的append方法是加了syncronized关键字的同步方法,但是经过逃逸分析后会发现StringBuffer的作用域只在concat方法内,它的引用不会逃逸出方法外,所以锁可以被消除掉。

锁粗化

通常为了提高程序的运行效率,我们一般会把锁的粒度尽量控制到最小,但是在某种情况下,如果一系列的操作都对同一个对象反复加锁和解锁,甚至出现在循环体之中,即使没有线程竞争,频繁进行互斥同步操作也会导致不必要的性能损耗,如果虚拟机探测到这种情况,会把锁的同步范围进行扩展,只需要加一次锁即可。

    public void append() {
        StringBuffer buffer = new StringBuffer();
        for (int i = 0; i <= 5; i++) {
            buffer.append(i);
        }
        System.out.println(buffer.toString());
    }

再看一个例子,在for循环中调用了StringBuffer的append方法,append方法是同步方法,会频繁的进行加锁解锁,带来性能的损耗,所以虚拟机对应这种情况会扩大锁的粒度,扩展到for循环之外,这样只需加锁一次就可以了。

锁升级

Mark Word对象头
为了提高空间使用率,Mark Word被设计为了一个非固定的动态数据结构。锁升级就是通过在Mark Word中设置标志位实现的。
接下来以32位的HotSpot虚拟机为例,看下各种锁状态下对应的对象头中的数据。

(1)无锁
无锁就是没有线程竞争锁的情况,在对象头中可以看出,有25bit存储对象的哈希码,4bit存储分代年龄,1bit用于存储偏向锁的状态,此时处于无锁的状态所以值是0,最后2bit存储锁状态的标志位,无锁的情况下对应标志位为01。

(2)偏向锁
当锁对象第一次被某个线程获取时,虚拟机会把对象头中锁状态的标志位设为01,偏向模式设为1,表示进入偏向模式,同时使用CAS把获取到锁这个线程的ID,记录在Mark Wor中。如果CAS操作成功,持有偏向锁的线程之后每次进入同步代码块时,虚拟机都不再进行任何同步操作。如果出现另外一个线程获取这个对象锁的情况,需要结束偏向锁,偏向模式设为0,标志位恢复到未锁定状态,之后升级为轻量级锁。如果当前只有一个线程获取锁,没有其他线程与其竞争,使用偏向锁可以提高效率。
在对象头中可以看到用23bit存储了获取锁的线程ID,1bit用于存储偏向锁的状态,此时进入偏向锁所以值是1,2bit存储锁状态的标志位,偏向锁对应标志位同样为01。

(3)轻量级锁
多线程竞争情况下,执行到同步代码块时,如果锁还未被占用,虚拟机会在当前线程的栈帧中建立一个锁记录(Lock Record)的空间,存储当前锁对象的Mrak Word拷贝,之后使用CAS操作将对象的Mrak Word更新为指向Lock Record的指针,如果更新成功,表示该线程获取到了此对象的锁。如果更新失败,会检查对象的Mrak Word是否指向当前线程的锁记录,如果是表示当前线程已经获取到了锁,直接执行同步代码块即可,如果不是自己,表示已经有其他线程已经获取到了锁,此时该线程通过自旋尝试获得锁,当自旋超过一定次数时,需要升级为重量级锁。

在对象头中可以看到用30bit存储了获取锁的线程ID,1bit用于存储偏向锁的状态,此时进入偏向锁所以值是1,2bit存储锁状态的标志位,轻量级锁对应标志位为00。

(4)重量级锁
重量级锁就是通过monitor,依赖操作系统Mutex Lock实现,会引起到内核态的切换,这个开销比较大,所以称之为重量级锁。
在对象头中可以看到用30bit存储了指向重量级锁的指针,2bit存储锁状态的标志位,重量级锁对应标志位同样为10。

锁升级的过程依次为:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。

Thread中的一些常用方法

sleep方法

sleep方法用于使线程进入睡眠状态,它可以指定睡眠的时间,并且会抛出InterruptedException异常,因为sleep方法会进入阻塞状态,处于阻塞状态的线程如果被中断,会抛出抛出InterruptedException异常。

sleep方法如果持有某个锁之后进入阻塞状态,此时并不会释放锁。

        @Override
        public void run() {
            try {
                Thread.currentThread().sleep(5000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

interrupt方法

interrupt方法可以中断处于阻塞状态的线程,处于阻塞状态的线程被中断时会抛出一个InterruptedException异常。

public class IntteruptTest {
    public static void main(String[] args) {
        Thread thread = new Thread(){
            @Override
            public void run() {
                try {
                    System.out.println("开始sleep");
                    // 进入睡眠
                    Thread.currentThread().sleep(10000);
                    System.out.println("结束sleep");
                } catch (InterruptedException e) { // 如果被中断
                    System.out.println("sleep被中断");
                    e.printStackTrace();
                }
                System.out.println("执行完毕");
            }
        };
        // 启动线程
        thread.start();
        // 中断线程
        thread.interrupt();
    }
}

输出:

开始sleep
sleep被中断
执行完毕
java.lang.InterruptedException: sleep interrupted
    at java.lang.Thread.sleep(Native Method)
    at Thread.IntteruptTest$1.run(IntteruptTest.java:25)
interrupted()方法和isInterrupted()方法

相同点

interrupted()方法和isInterrupted()方法都可以用来获取线程的中断状态,中断状态表示线程是否被中断,初始化及未被中断过的时候,中断状态值为false,当线程被中断后,中断状态为true。

不同点

(1)调用方式不一样:

  • interrupted()是Thread类中的方法,可以直接通过Thread.interrupted()进行调用;
  • isInterrupted()方法需要获取到当前的线程再继续调用Thread.currentThread().isInterrupted()

(2)interrupted()方法调用的时候会判断中断状态的值,如果已经被中断过(值为true),会将其恢复为false状态。

public class Test {
    public static void main(String[] args) {
        // 初始状态,当前线程未被中断过,所以返回false
        System.out.println("1. " + Thread.currentThread().isInterrupted());
        // 当前线程未被中断过,同样返回false
        System.out.println("2. " + Thread.interrupted());
        // 中断当前线程
        Thread.currentThread().interrupt();
        // 线程被中断,此时isInterrupted()返回true
        System.out.println("3. " + Thread.currentThread().isInterrupted());
        // interrupted同样返回true,表示线程被中断过,处于此状态时,它会将中断标识的状态由true置为false,恢复未中断的状态
        System.out.println("4. " + Thread.interrupted());
        // 由于中断状态被上一步的Thread.interrupted()恢复为了false,所以这里返回false
        System.out.println("5. " + Thread.currentThread().isInterrupted());
        // 中断状态已经处于false状态,所以同样返回false
        System.out.println("6. " + Thread.interrupted());
    }
}

输出:

1. false
2. false
3. true
4. true
5. false
6. false

从源码中可以看到 isInterrupted调用的是navtive的isInterrupted方法来实现的:

public class Thread implements Runnable {
   public boolean isInterrupted() {
       // 调用原生的isInterrupted
        return isInterrupted(false);
   }

    // native方法
    private native boolean isInterrupted(boolean ClearInterrupted);
}

interrupted()方法,底层也是调用了navtive的isInterrupted方法,只不过在参数中传入了true,表示恢复中断标识:

public class Thread implements Runnable {
    public static boolean interrupted() {
        // isInterrupted是native方法
        return currentThread().isInterrupted(true);
    }
}

join方法

如果调用某个Thread对象的join方法时,会将调用者所处的线程进入阻塞状态,直到Thread对象中的任务运行完毕,再恢复调用者的线程继续往下运行。

来看个例子:

public class Test {
    public static void main(String[] args) {
        Thread taskOne = new Thread(new TaskOne());
        taskOne.start();
        System.out.println("Main线程");
    }
}

class TaskOne implements Runnable {
    @Override
    public void run() {
        try {
            Thread.sleep(3000);
            System.out.println("任务一运行完毕");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

输出:

Main线程
任务一运行完毕

从输出结果中可以看到Main线程打印之后,任务一才运行完毕,假如需要等任务一结束之后才执行Main线程,就可以使用到join方法:

public class Test {
    public static void main(String[] args) throws InterruptedException {
        Thread taskOne = new Thread(new TaskOne());
        taskOne.start();
        // 当前线程是Main线程,调用taskOne的join方法时,会使Main线程进入阻塞状态,直到taskOne的run方法中的任务执行完毕
        taskOne.join();
        // taskOne任务结束之后,恢复Main线程的运行
        System.out.println("Main线程");
    }
}

输出:

任务一运行完毕
Main线程

join方法实现原理

join后端是通过调用wait方法实现的,wait方法需要保证线程先获取到锁,所以在join方法中可以看到使用了synchronized关键字,当调用join的方法的线程获取到锁之后,进入方法内,如果join方法未设置等待时间,会不断调用isAlive方法检查需要等待的线程是否运行完毕,如果未运行完毕,会使调用线程进入等待状态。

以上面的例子来看,Main线程调用了taskOne的join方法,Main线程执行join方法之前首先需要获取taskOne的对象锁,获取成功之后进入方法体内,由于调用join未设置等待时间,所以进入if分支,开启循环调用isAlive检查taskOne对应的线程是否存活(run方法中的任务是否执行完毕),如果存活表示线程还未结束,Main线程需要继续等待,此时调用wait方法让Main线程进入阻塞状态,当taskOne线程中的任务执行结束之后,会唤醒Main线程(需要查看JVM源码才能找到):

 public final synchronized void join(long millis)
    throws InterruptedException {
        long base = System.currentTimeMillis();
        long now = 0;

        if (millis < 0) {
            throw new IllegalArgumentException("timeout value is negative");
        }
        // 如果未设置等待时间
        if (millis == 0) {
            // 不断检查需要等待的线程是否存活
            while (isAlive()) {
                // 如果被等待的线程还未运行完毕,调用wait方法使调用者所在线程进入等待状态
                wait(0);
            }
        } else {
            while (isAlive()) {
                long delay = millis - now;
                if (delay <= 0) {
                    break;
                }
                wait(delay);
                now = System.currentTimeMillis() - base;
            }
        }
    }

线程间的协作

wait()notify notifyAll()都是Object类中的方法,并且都是native(本地)方法。

public class Object {
    public final void wait() throws InterruptedException {
        wait(0);
    }
    public final native void wait(long timeout) throws InterruptedException;
    public final native void notify();
    public final native void notifyAll();
}

wait()调用对象的wait方法可以使调用的线程进入阻塞状态,wait方法也可以指定等待的时间,注意调用wait方法之前需要先获取到对象的锁,进入等待状态后wait方法会释放对象的锁。

notify()调用对象的notify方法可以唤醒一个正在等待该对象锁的线程,如果有多个线程都在等待,只能唤醒其中之一。

notifyAll()调用对象的notifyAll方法可以唤醒正在等待该对象锁的所有线程,需要注意这里只是唤醒,表示线程可以去竞争锁,并不能保证唤醒之后就能获取到CPU执行权。

public class WaitTest {

    public static void main(String[] args) {
        Object obj = new Object();

        new Thread(){
            @Override
            public void run() {
                // 首先需要获取锁
                synchronized (obj) {
                    System.out.println("线程一准备进入等待");
                    try {
                        // 调用wait方法使当前线程进入阻塞等待状态,进入之后会释放obj锁
                        obj.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("线程一被唤醒");
                }
            }
        }.start();

        new Thread(){
            @Override
            public void run() {
                // 这里先睡眠,尽量保证线程一先执行
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (obj) {
                    System.out.println("线程二准备唤醒等待obj对象锁的线程");
                    // 调用notify方法唤醒在等待获取obj锁的线程,notify方法之后会继续往下进行
                    obj.notify();
                    // 待方法执行完毕之后结束任务释放锁
                    System.out.println("线程二结束执行");
                }
            }
        }.start();
    }
}

输出:

线程一准备进入等待
线程二准备唤醒等待obj对象锁的线程
线程二结束执行
线程一被唤醒

从输出结果中可以看出,线程二调用notify方法会唤醒正在等待obj对象锁的线程一,然后线程二继续往下执行,执行完毕释放锁之后,线程一才可以获取到锁继续向后执行。

wait() 和 sleep()方法的不同点

  1. 所在的类不同,wait属于Object类中的方法,而sleep属于Thread类中的方法
// Object类
public class Object {
    public final void wait() throws InterruptedException {
        wait(0);
    }
}

// Thread类
public class Thread implements Runnable {
    public static void sleep(long millis, int nanos) throws InterruptedException {
        // ....
    }
    public static native void sleep(long millis) throws InterruptedException;
}
  1. sleep是让某个线程休眠,让出CPU执行权进入阻塞状态,既可以在线程持有某个锁时进行休眠,也可以在未持有锁时休眠,如果线程在持有某个锁的时候调用sleep方法并不会释放锁。
  2. wait是Object类中的方法,所以需要通过某个对象进行调用,调用某个对象的wait方法会使调用的线程进入等待状态,当然前提是该线程已经获得到这个对象上的锁,调用wait方法后进入阻塞状态时会释放掉该对象上的锁,之后等待被唤醒。

Condition

java 1.5之后推出了Condition,用于替代Object类中的wait()notify notifyAll()方法。Condition是一个接口await()signalt() signalAll()分别对应Object中的wait()notify notifyAll()方法。

Condition需要配合Lock接口使用,使用Condition改造上面的例子:

public class ConditionTest {
    
    public static void main(String[] args) {
        Lock lock = new ReentrantLock();
        // 通过Lock创建Condition,与Lock上的锁进行绑定
        Condition condition = lock.newCondition();
        
        new Thread(){
            @Override
            public void run() {
                // 首先需要获取锁
                lock.lock();
                System.out.println("线程一准备进入等待");
                try {
                    // 调用await方法使当前线程进入阻塞等待状态,会释放lock锁
                    condition.await();
                    System.out.println("线程一被唤醒");
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    // 需要释放锁
                    lock.unlock();
                }
            }
        }.start();

        new Thread(){
            @Override
            public void run() {
                // 这里先睡眠,尽量保证线程一先执行
                try {
                    Thread.sleep(1000);
                    lock.lock();
                    System.out.println("线程二准备唤醒等待lock锁的线程");
                    // 调用signal方法唤醒在等待获取lock锁的线程,notify方法之后会继续往下进行
                    condition.signal();
                    System.out.println("线程二结束执行");
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    // 需要释放锁
                    lock.unlock();
                }

            }
        }.start();
    }
}

输出:

线程一准备进入等待
线程二准备唤醒等待lock锁的线程
线程二结束执行
线程一被唤醒

CountDownLatch和CyclicBarrier的区别

CountDownLatch

CountDownLatch用于某个线程等待其他线程的任务执行完毕之后再执行。
在创建CountDownLatch的时候可以指定值,调用countDown()方法会使值减1,调用await()方法会使调用线程进入阻塞状态,直到CountDownLatch的值变为0再继续向下执行。

public class CountDownLatchTest {
    public static void main(String[] args) {
        // 创建CountDownLatch,可以指定值,这里设为2
        CountDownLatch countDownLatch = new CountDownLatch(2);

        // 创建任务1
        new Thread(){
            @Override
            public void run() {
                try {
                    System.out.println("任务一正在运行");
                    Thread.sleep(3000);
                    // 值减1
                    countDownLatch.countDown();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }.start();

        // 创建任务2
        new Thread(){
            @Override
            public void run() {
                try {
                    System.out.println("任务二正在运行");
                    Thread.sleep(1000);
                    // 值减1
                    countDownLatch.countDown();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }.start();

        try {
            System.out.println("等待任务执行完毕");
            // await()方法会使线程进入阻塞状态,countDownLatch的值变为0再继续执行
            countDownLatch.await();
            System.out.println("所有任务执行完毕");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

输出:

任务一正在运行
等待任务执行完毕
任务二正在运行
所有任务执行完毕

从输出结果中可以看到,任务一执行完毕之后,会等待任务二执行完毕之后,才会执行await()方法后面的代码。

CyclicBarrier

CyclicBarrier用于一组线程,等待组内其他线程进入某个状态时,组内的所有线程再同时往下进行。

CyclicBarrier待所有的线程资源释放后,可以重新使用,这也是被称之为回环的原因。

public class CyclicBarrierTest {

    public static void main(String[] args) {
        // 创建CyclicBarrier,数量设为2
        CyclicBarrier cyclicBarrier = new CyclicBarrier(2);
        // 创建任务1
        new Thread(){
            @Override
            public void run() {
                try {
                    System.out.println("任务一正在运行");
                    Thread.sleep(3000);
                    // 这里等待其他线程
                    // 所有线程都进入等待状态后,所有线程才可以进行往下进行
                    System.out.println("任务一进入等待");
                    cyclicBarrier.await();
                    System.out.println("任务一继续执行");
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            }
        }.start();

        // 创建任务2
        new Thread(){
            @Override
            public void run() {
                try {
                    System.out.println("任务二正在运行");
                    Thread.sleep(1000);
                    System.out.println("任务二进入等待");
                     // 这里等待其他线程
                    cyclicBarrier.await();
                    System.out.println("任务二继续执行");
                } catch (Exception e) {
                    throw new RuntimeException(e);
                }
            }
        }.start();
    }
}

输出:

任务一正在运行
任务二正在运行
任务二进入等待
任务一进入等待
任务一继续执行
任务二继续执行

从输出结果中可以看到,任务二先执行到await()方法,此时任务一还在运行中,所以任务二等待任务一,当任务一也执行到await()方法时,再同时执行await()方法后面的代码。

Semaphore信号量

Semaphore可以用来控制访问受保护资源的线程个数。

在创建Semaphore的时候可以指定个数,表示有多少个线程可以获取到锁,acquire()方法用于获取一个锁,release()方法用于释放一个锁,当指定数量的锁获取完毕之后,如果有新的线程调用acquire方法获取锁只能等待其他线程释放。

public class SemaphoreTest {
    public static void main(String[] args) {
        // 创建Semaphore,值设置为3,表示可以有3个线程获取到锁
        Semaphore semaphore = new Semaphore(3);
        // 创建5个线程
        for(int i=1; i<=5; i++) {
            int j = i;
            new Thread(){
                @Override
                public void run() {
                    try {
                        // 获取锁
                        semaphore.acquire();
                        System.out.println("第"+j+"个线程获取到锁,开始执行");
                        Thread.sleep(new Random().nextInt(5000));
                        System.out.println("第"+j+"个线程执行完毕,并释放锁");
                        // 释放锁
                        semaphore.release();
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }

                }
            }.start();
        }
    }
}

输出:

第1个线程获取到锁,开始执行
第2个线程获取到锁,开始执行
第3个线程获取到锁,开始执行
第1个线程执行完毕,并释放锁
第4个线程获取到锁,开始执行
第4个线程执行完毕,并释放锁
第5个线程获取到锁,开始执行
第5个线程执行完毕,并释放锁
第3个线程执行完毕,并释放锁
第2个线程执行完毕,并释放锁

由于设置了锁的数量,从输出结果中可以看到,前三个线程获取到锁之后,其他线程只能等待,当线程1执行完毕释放锁之后,线程4才能申请到锁,执行任务。

ThreadLocal的实现原理

ThreadLocal线程本地变量,它会为每个线程创建一个副本,每个线程之间的副本互不影响。来看个例子:

public class ThreadLocalTest {

    public static void main(String[] args) {
        // 创建线程
        new Thread(new Task()).start();
        new Thread(new Task()).start();
    }
}

class Task implements Runnable {
    
    private ThreadLocal<Integer> threadLocal = new ThreadLocal();

    @Override
    public void run() {
        // 设置变量的值
        threadLocal.set(new Random().nextInt(100));
        System.out.println(String.format("当前线程:%s,变量值为:%s", Thread.currentThread().getName(), threadLocal.get()));
    }
}

输出:

当前线程:Thread-0,变量值为:67
当前线程:Thread-1,变量值为:11

可以看到通过ThreadLocal可以为每个线程设置自己的变量,并且互不影响。

实现原理

每个线程有一个ThreadLocalMap类型的成员变量,ThreadLocalMap是ThreadLocal的内部类,里面有个Entry类型的哈希表,Entry是ThreadLocalMap的内部类,它继承了WeakReference所以它是一个弱引用,Entry中的Key是ThreadLocal类型的对象,value是Object类型的,存储线程对应的变量:

public class Thread implements Runnable {
    // 每个线程有一个ThreadLocalMap类型的成员变量
    ThreadLocal.ThreadLocalMap threadLocals = null;
}

public class ThreadLocal<T> {
    static class ThreadLocalMap {
        static class Entry extends WeakReference<ThreadLocal<?>> { // 继承了WeakReference
            /** 存储每个线程对应的变量 */
            Object value;
            Entry(ThreadLocal<?> k, Object v) { // 这里的KEY是ThreadLocal类型的对象
                super(k);
                value = v;
            }
        }
        // 哈希表
        private Entry[] table;
    }
}


由于一个线程可以绑定不同的ThreadLocal对象,所以采用这种方式,在Thread中设置一个Map,Key为不同的ThreadLocal对象,Value为线程在该对象上绑定的值。

set方法的处理逻辑
(1)获取当前线程;
(2)根据当前线程获取对应的ThreadLocalMap,从getMap方法中可以看出,返回的是Thread类中的threadLocals成员变量;
(3)判断获取到的ThreadLocalMap是否为空:

  • 如果不为空,将当前线程绑定的变量设置到ThreadLocalMap中,其中Key为this指向当前对象,也就是当前的ThreadLocal对象;
  • 如果为空,需要创建ThreadLocalMap,并将变量加入到ThreadLocalMap;
public class ThreadLocal<T> {
    public void set(T value) {
        // 获取当前线程
        Thread t = Thread.currentThread();
        // 获取线程对应的ThreadLocalMap
        ThreadLocalMap map = getMap(t);
        // 如果不为空,直接将value加入ThreadLocalMap,this为当前对象,也就是ThreadLocal对象
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value); // 实例化ThreadLocalMap并将value加入
    }
    
    ThreadLocalMap getMap(Thread t) {
        // 返回当前线程对应的ThreadLocalMap
        return t.threadLocals;
    }
}

get方法的处理逻辑
(1)获取当前线程;
(2)获取当前线程对应的ThreadLocalMap,返回的是Thread中的threadLocals变量;
(3)从ThreadLocalMap中,获取对应的Entry,注意这里传入的key是this,this指向的是ThreadLocal实例本身;
(4)从entry中获取value返回,value即为线程对应的变量的值;

public class ThreadLocal<T> {
    public T get() {
        // 获取当前线程
        Thread t = Thread.currentThread();
        // 获取当前线程对应的ThreadLocalMap
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            // 从ThreadLocalMap中获取当前对象对应的值,注意这里传入的是this不是当前线程,是当前的ThreadLocal对象
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                // 获取value
                T result = (T)e.value;
                return result;
            }
        }
        // 初始化
        return setInitialValue();
    }
    
    ThreadLocalMap getMap(Thread t) {
        // 返回当前线程对应的ThreadLocalMap
        return t.threadLocals;
    }

}

引用关系

Entry中的KEY引用的ThreadLocal对象是弱引用,JVM进行垃圾回收时不管有没有足够的空间都会把弱引用关联的对象回收掉,但是ThreadLocal对象还存在一条强引用关系,也就是虚拟机栈中的变量对它的引用关系,只要这个强引用关系还在,堆中的ThreadLocal对象就不会被回收。

class Task implements Runnable {
    
    private ThreadLocal<Integer> threadLocal = new ThreadLocal();

    @Override
    public void run() {
        // 设置变量的值
        threadLocal.set(new Random().nextInt(100));
        System.out.println(String.format("当前线程:%s,变量值为:%s", Thread.currentThread().getName(), threadLocal.get()));
    }
}

以上面的例子来看,只要Task对象还在使用中,threadLocal变量(栈)与ThreadLocal对象(堆)之间的强引用关系就会一直存在,ThreadLocal对象就不会被回收。
内存泄漏问题
当任务运行完毕之后,Thread线程可能并不会结束(比如线程池中一个任务运行完毕,会继续执行下一个任务,线程并未被销毁),Thread与ThreadLocalMap之间的强引用关系会一直存在,导致ThreadLocalMap与Entry的强引用也在,不过Entry到ThreadLocal对象之间是弱引用关系,当栈中对ThreadLocal对象的强引用失效时,在JVM进行垃圾回收时就可以对ThreadLocal对象回收,这个时候Entry中的Key已经为NULL,但是对应的value无法被回收,还存在一条强引用链,如果有大量的ThreadLocal对象,容易造成内存泄露(内存泄露指的是一些未被使用的对象由于某些原因不能被垃圾回收器回收,最终导致内存空间不足):

所以在使用ThreadLocal时,如果结束对变量的使用,可以调用remove()方法从ThreadLocalMap中清除对应数据:

public class ThreadLocal<T> {
  public void remove() {
         // 获取ThreadLocalMap
         ThreadLocalMap m = getMap(Thread.currentThread());
         if (m != null)
             m.remove(this); // 移除当前ThreadLocal对象对应的Entry
     }
}

线程池的几个参数

(1)maximumPoolSize:线程池中能创建最大的线程数量;

(2)corePoolSize:核心线程数量,向线程池提交任务时,会进行如下判断:

  • 如果当前线程池中线程的个数小于corePoolSize,会新创建一个线程来处理任务,即使线程池中的其他线程处于空闲状态;
  • 如果线程池中的线程数量大于等于corePoolSize且小于maximumPoolSize,只有当workQueue满时才创建新的线程去处理任务,否则将请求放入workQueue中,等待有空闲的线程去从workQueue中取任务并处理;;
  • 如果创建的线程数量大于等于maximumPoolSize,并且workQueue已经满,则根据Handler设置的拒绝策略来处理;

(3)workQueue:一个阻塞队列,当线程池中线程的个数大于等于corePoolSize并且小于maximumPoolSize时,用来存储等待执行的任务,待其他线程空闲时会从队列中获取任务进行处理,常用的阻塞队列有以下几种:

  • ArrayBlockingQueue:基于数组结构的有界阻塞队列;
  • LinkedBlockingQuene:基于链表结构的阻塞队列;
  • SynchronousQuene:不存储元素的阻塞队列,当进行插入操作的时候,必须有一个线程在进行取出操作,否则插入操作需要等待,直到有线程进行取出操作。
  • PriorityBlockingQuene:具有优先级的无界阻塞队列;

(4)keepAliveTime:当线程池中的线程数量大于corePoolSize的时候,如果此时没有新任务提交,并且阻塞队列中也没有任务需要处理,线程会处于空闲状态,当空闲的时间超过了keepAliveTime,则核心线程数量以外的线程会被销毁。

(5)threadFactory:线程工厂,用于创建线程使用。

(6)handler:线程池任务已满时的处理策略,它是RejectedExecutionHandler类型的变量。如果阻塞队列已满,并且创建的线程数也达到了maximumPoolSize,此时向线程池提交新任务,线程池需要采取一些措施,常用的线程池提交的策略如下:

  • AbortPolicy:丢弃任务并抛出异常,是默认策略;
  • DiscardPolicy:丢弃任务但是不抛出异常;
  • CallerRunsPolicy:用调用者所在的线程,也就是向线程池中提交任务的那个线程来执行任务;
  • DiscardOldestPolicy:丢弃阻塞队列中最前面的任务(从队列头部移除元素),并将当前任务提交到线程池;
    除了以上四种,也可以自定义线程池拒绝策略。

参考

【周志明】深入理解Java虚拟机

【Matrix海 子】Java并发编程-入门篇

【Idea Buffer】深入理解Java线程池:ThreadPoolExecutor)

posted @ 2023-08-02 23:38  shanml  阅读(66)  评论(0编辑  收藏  举报