跳至主要內容

并发编程手册(基础)

bugcode2024年11月16日大约 135 分钟面试JAVA面试java

并发编程(基础)

么是进程?是什么线程?

线程是处理器任务调度和执行的基本单位,进程是操作系统资源分配的基本单位。 进程是程序的一次执行过程,是系统运行的基本单位。线程是一个比进程更小的执行单位,一个进程可以包含多个线程。

进程和线程的关系?(区别)

定义:

包含关系:一个进程可以包含多个线程。

讲解线程和进程的时候可以从jvm角度回答

1631509794280
1631509794280

由上面可知以下进程和线程在以下几个方面的区别:

并行和并发的区别?

什么是线程安全和线程不安全?

线程安全

线程不安全

多线程的优缺点(为什么使用多线程、多线程会引发什么问题)

优点:当一个线程进入等待状态或者阻塞时,CPU可以先去执行其他线程,提高CPU的利用率

缺点:

什么是多线程的上下文切换?

多线程:是指从软件或者硬件上实现多个线程的并发技术。 多线程的好处:

  1. 使用多线程可以把程序中占据时间长的任务放到后台去处理,如图片、视屏的下载
  2. 发挥多核处理器的优势,并发执行让系统运行的更快、更流畅,⽤户体验更好

多线程的缺点:

  1. ⼤量的线程降低代码的可读性;
  2. 更多的线程需要更多的内存空间
  3. 当多个线程对同一个资源出现争夺时候要注意线程安全的问题。

多线程的上下文切换:

Java中守护线程和用户线程的区别?

任何线程都可以设置为守护线程和用户线程,通过方法 Thread.setDaemon(bool on) 设置, true 则 是将该线程设置为守护线程, false 则是将该线程设置为用户线程。同时, Thread.setDaemon() 必须 在 Thread.start() 之前调用,否则运行时会抛出异常。

线程死锁是如何产生的,如何避免

产生死锁的原因

死锁:由于两个或两个以上的线程相互竞争对方的资源,而同时不释放自己的资源,导致所有线程同时 被阻塞

死锁产生的条件:

避免死锁的方法主要是破坏死锁产生的条件。

用Java实现死锁,并给出避免死锁的解决方案

代码演示

class DeadLockDemo {
    private static Object resource1 = new Object();
    private static Object resource2 = new Object();
    public static void main(String[] args) {
        new Thread(() -> {
            synchronized (resource1) {
                System.out.println(Thread.currentThread() + "get resource1");
                try {
                    ead.sleep(1000); //线程休眠,保证线程2先获得资源2
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting getresource2");
                synchronized (resource2) {
                    System.out.println(Thread.currentThread() + "getresource2");
                }
            }
        }, "线程 1").start();
        
        new Thread(() -> {
            synchronized (resource2) {
                System.out.println(Thread.currentThread() + "get resource2");
                try {
                    Thread.sleep(1000); //线程休眠,保证线程1先获得资源1
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread() + "waiting getresource1");
                synchronized (resource1) {
                    System.out.println(Thread.currentThread() + "getresource1");
                }
            }
        }, "线程 2").start();
    }
}
//结果
Thread[线程 1,5,main]get resource1
Thread[线程 2,5,main]waiting get resource1
Thread[线程 1,5,main]waiting get resource2

上面代码产生死锁的原因主要是线程1获取到了资源1,线程2获取到了资源2,线程1继续获取资源2而产 生阻塞,线程2继续获取资源1而产生阻塞。解决该问题最简单的方式就是两个线程按顺序获取资源,线 程1和线程2都先获取资源1再获取资源2,无论哪个线程先获取到资源1,另一个线程都会因无法获取线 程1产生阻塞,等到先获取到资源1的线程释放资源1,另一个线程获取资源1,这样两个线程可以轮流获 取资源1和资源2。代码如下:

private static Object resource1 = new Object();
private static Object resource2 = new Object();
public static void main(String[] args) {
    new Thread(() -> {
        synchronized (resource1) {
            System.out.println(Thread.currentThread() + "get resource1");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread() + "waiting getresource2");
            synchronized (resource2) {
                System.out.println(Thread.currentThread() + "getresource2");
            }
        }
    }, "线程 1").start();
    new Thread(() -> {
        synchronized (resource1) {
            System.out.println(Thread.currentThread() + "get resource1");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
            e.printStackTrace();
            }
            System.out.println(Thread.currentThread() + "waiting getresource2");
            synchronized (resource2) {
                System.out.println(Thread.currentThread() + "getresource2");
            }
        }
    }, "线程 2").start();
    }
}

Java中的死锁、活锁、饥饿有什么区别?

活锁:任务或者执行者没有被阻塞,由于某些条件没有被满足,导致线程一直重复尝试、失败、尝试、 失败。例如,线程1和线程2都需要获取一个资源,但他们同时让其他线程先获取该资源,两个线程一直 谦让,最后都无法获取

活锁和死锁的区别:

饥饿:一个或者多个线程因为种种原因无法获得所需要的资源, 导致一直无法执行的状态。以打印机打 印文件为例,当有多个线程需要打印文件,系统按照短文件优先的策略进行打印,但当短文件的打印任 务一直不间断地出现,那长文件的打印任务会被一直推迟,导致饥饿。活锁就是在忙式等待条件下发生 的饥饿,忙式等待就是不进入等待状态的等待。 产生饥饿的原因:

死锁、饥饿的区别:饥饿可自行解开,死锁不行。

线程的生命周期和状态

线程状态的划分并不唯一,但是都大同小异,这里参考《Java并发编程的艺术》,主要有以下几种状态:

  1. 新建( new ):新创建了一个线程对象。
  2. 可运行( runnable ):线程对象创建后,其他线程(比如 main 线程)调用了该对象 的 start () 方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获 取 cpu 的使用权 。
  3. 运行( running ):可运行状态( runnable )的线程获得了 cpu 时间片( timeslice ) ,执行 程序代码。
  4. 阻塞( block ):阻塞状态是指线程因为某种原因放弃了 cpu 使用权,也即让出了 cpu timeslice ,暂时停止运行。直到线程进入可运行( runnable )状态,才有 机会再次获得 cpu timeslice 转到运行( running )状态。阻塞的情况分三种:
    1. 等待阻塞:运行( running )的线程执行 o . wait ()方法, JVM 会把该线程放 入等待队列( waitting queue )中。
    2. 同步阻塞:运行( running )的线程在获取对象的同步锁时,若该同步锁 被别的线程占 用,则 JVM 会把该线程放入锁池( lock pool )中。
    3. 其他阻塞: 运行( running )的线程执行 Thread . sleep ( long ms )或 t . join ()方法,或者发出了 I / O 请求时, JVM 会把该线程置为阻塞状态。 当 sleep ()状态超时、 join () 等待线程终止或者超时、或者 I / O 处理完毕时,线程重新转入可运行( runnable )状态。
  5. 死亡( dead ):线程 run ()、 main () 方法执行结束,或者因异常退出了 run ()方法,则该 线程结束生命周期。死亡的线程不可再次复生。
1631511097439
1631511097439

线程转化过程如下:

1631511128745
1631511128745
1632049757159
1632049757159

创建线程一共有哪几种方法?

实现Runnable接口这种方式更受欢迎,因为这不需要继承Thread类。在应用设计中已经继承了别的对象的情况下,这需要多继承(而Java不支持多继承),只能实现接口。同时, 线程池也是非常高效的,很容易实现和使用。

  1. 继承Thread类创建线程,首先继承Thread类,重写 run() 方法,在 main() 函数中调用子类实实例的** start() 方法。*
public class ThreadDemo extends Thread {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " run()方法正在执行");
    }
}
public class TheadTest {
    public static void main(String[] args) {
        ThreadDemo threadDemo = new ThreadDemo();
        threadDemo.start();
        System.out.println(Thread.currentThread().getName() + " main()方法执行结束");
    }
}
//结果
main main()方法执行结束
Thread-0 run()方法正在执行
  1. 实现Runnable接口创建线程:首先创建实现 Runnable 接口的类 RunnableDemo ,重写 run() 方法; 创建类 RunnableDemo 的实例对象 runnableDemo ,以 runnableDemo 作为参数创建 Thread 对象,调用 Thread 对象的 start() 方法。
public class RunnableDemo implements Runnable {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + " run()方法执行中");
    }
}
public class RunnableTest {
    public static void main(String[] args) {
        RunnableDemo runnableDemo = new RunnableDemo ();
        Thread thread = new Thread(runnableDemo);
        thread.start();
        System.out.println(Thread.currentThread().getName() + " main()方法执行完成");
    }
}
//结果
main main()方法执行完成
Thread-0 run()方法执行中
  1. 使用Callable和Future创建线程:
    1. 创建Callable接口的实现类 CallableDemo ,重写 call() 方法。
    2. 以类 CallableDemo 的实例化对象作为参数创建 FutureTask 对象。
    3. 以 FutureTask 对象作为参数创建 Thread 对象。
    4. 调用 Thread 对象的 start() 方法。
class CallableDemo implements Callable<Integer> {
    @Override
    public Integer call() {
        System.out.println(Thread.currentThread().getName() + " call()方法执行中");
    return 0;
    }
}
class CallableTest {
    public static void main(String[] args) throws ExecutionException,InterruptedException {
        FutureTask<Integer> futureTask = new FutureTask<Integer>(newCallableDemo());
        Thread thread = new Thread(futureTask);
        thread.start();
        System.out.println("返回结果 " + futureTask.get());
        System.out.println(Thread.currentThread().getName() + " main()方法执行完成");
    }
}
  1. 使用线程池例如用Executor框架: Executors 可提供四种线程池,分别为:
    1. newCachedThreadPool 创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程
    2. newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
    3. newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。
    4. newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序执行。

下面以创建一个定长线程池为例进行说明:

class ThreadDemo extends Thread {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName() + "正在执行");
    }
}
class TestFixedThreadPool {
    public static void main(String[] args) {
    //创建一个可重用固定线程数的线程池
    ExecutorService pool = Executors.newFixedThreadPool(2);
    //创建实现了Runnable接口对象,Thread对象当然也实现了Runnable接口
    Thread t1 = new ThreadDemo();
    Thread t2 = new ThreadDemo();
    Thread t3 = new ThreadDemo();
    Thread t4 = new ThreadDemo();
    Thread t5 = new ThreadDemo();
    //将线程放入池中进行执行
    pool.execute(t1);
    pool.execute(t2);
    pool.execute(t3);
    pool.execute(t4);
    pool.execute(t5);
    //关闭线程池
    pool.shutdown();
    }
}
//结果
pool-1-thread-2正在执行
pool-1-thread-1正在执行
pool-1-thread-1正在执行
pool-1-thread-2正在执行
pool-1-thread-1正在执行

runnable 和 callable 有什么区别?

相同点:

不同点:

线程的run()和start()有什么区别?

为什么调用start()方法时会执行run()方法,而不直接执行run()方法?

  1. start() 方法来启动线程,真正实现了多线程运行,这时无需等待 run() 方法体代码执行完毕而直接继续执行下面的代码。通过调用Thread类的 start() 方法来启动一个线程,这时此线程处于就绪(可运行)状态,并没有运行,一旦得到cpu时间片,就开始执行 run() 方法,这里方法 run() 称为线程体,它包含了要执行的这个线程的内容, run() 方法运行结束,此线程随即终止。
  2. run() 方法只是类的一个普通方法而已,如果直接调用 run 方法,程序中依然只有主线程这一个线程,其程序执行路径还是只有一条,还是要顺序执行,还是要等待 run() 方法体执行完毕后才可继续执行下面的代码,这样就没有达到写线程的目的。
  3. 调用 start() 方法可以开启一个线程,而 run() 方法只是thread类中的一个普通方法,直接调用run() 方法还是在主线程中执行的。

什么是自旋锁?

  1. 当线程A想要获取一把自旋锁而该锁又被其它线程锁持有时,线程A会在⼀个循环中自旋以检测锁是不是已经可用了。
  2. 自选锁需要注意:
    1. 由于自旋时不不释放CPU,因而持有⾃旋锁的线程应该尽快释放⾃旋锁,否则等待该自旋锁的线程会⼀直在那里自 旋,这就会浪费CPU时间。
    2. 持有自旋锁的线程在sleep之前应该释放⾃旋锁以便其它线程可以获得自旋锁。
  3. ⽬前的JVM实现自旋会消耗CPU,如果长时间不不调用doNotify⽅方法,doWait⽅法会⼀直自旋,CPU会消耗太大。
  4. ⾃旋锁比较适用于锁使用者保持锁时间比较短的情况,这种情况自旋锁的效率比较高。
  5. ⾃旋锁是一种对多处理器相当有效的机制,而在单处理器非抢占式的系统中基本上没有作用。

什么是CAS?

  1. CAS(compare and swap)的缩写,中文翻译成比较并交换。
  2. CAS 不通过JVM,直接利用java本地方 法JNI(Java Native Interface为JAVA本地调⽤),直接调用CPU 的cmpxchg(是 汇编指令)指令。
  3. 利⽤CPU的CAS指令,同时借助JNI来完成Java的非阻塞算法,实现原子操作。其它原⼦操作都是利用类似的特性完成 的。
  4. 整个java.util.concurrent都是建立在CAS之上的,因此对于synchronized阻塞算法,J.U.C在性能上有了了很大的提升。
  5. CAS是乐观锁技术,当多个线程尝试使用CAS同时更新同⼀个变量时,只有其中⼀个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。
  1. 使⽤CAS在线程冲突严重时,会⼤幅降低程序性能;CAS只适合于线程冲突较少的情况使⽤。
  2. synchronized在jdk1.6之后,已经改进优化。synchronized的底层实现主要依靠Lock-Free的队列,基本思路是⾃旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了⾼吞吐量。在线程冲突较少的情况下,可以获得和CAS类似的性能;⽽线程冲突严重的情况下,性能远⾼于CAS。

什么是乐观锁和悲观锁?

悲观锁

乐观锁

什么是AQS

  1. AbstractQueuedSynchronizer简称AQS,是一个用于构建锁和同步容器的框架。事实上concurrent包内许多类都是基于AQS构建,例如ReentrantLock,Semaphore,CountDownLatch,ReentrantReadWriteLock,FutureTask等。AQS解决了在实现同步容器时设计的大量细节问题。
  2. AQS使用⼀个FIFO的队列表示排队等待锁的线程,队列头节点称作“哨兵节点”或者“哑节点”,它不与任何线程关联。其他的节点与等待线程关联,每个节点维护一个等待状态waitStatus。

什么是阻塞队列

JDK7提供了了7个阻塞队列列。(也属于并发容器)

  1. ArrayBlockingQueue :⼀个由数组结构组成的有界阻塞队列。
  2. LinkedBlockingQueue :一个由链表结构组成的有界阻塞队列。
  3. PriorityBlockingQueue :一个⽀持优先级排序的无界阻塞队列。
  4. DelayQueue:一个使用优先级队列实现的无界阻塞队列。
  5. SynchronousQueue:⼀个不存储元素的阻塞队列。
  6. LinkedTransferQueue:⼀个由链表结构组成的无界阻塞队列。
  7. LinkedBlockingDeque:⼀个由链表结构组成的双向阻塞队列。

阻塞队列是⼀个在队列基础上又支持了了两个附加操作的队列。

  • ⽀持阻塞的插入⽅法:队列满时,队列会阻塞插入元素的线程,直到队列不满。
  • 支持阻塞的移除方法:队列空时,获取元素的线程会等待队列变为非空。

什么是Callable和Future?

  1. Callable 和 Future 是比较有趣的一对组合。当我们需要获取线程的执行结果时,就需要⽤到它们。Callable用于产生结果,Future⽤于获取结果。
  2. Callable接口使用泛型去定义它的返回类型。Executors类提供了⼀些有用的方法在线程池中执行Callable内的任务。由于Callable任务是并行的,必须等待它返回的结果。java.util.concurrent.Future对象解决了了这个问题。
  3. 在线程池提交Callable任务后返回了一个Future对象,使用它可以知道Callable任务的状态和得到Callable返回的执行结果。Future提供了了get()⽅法,等待Callable结束并获取它的执行结果。

什么是FutureTask?

  1. FutureTask可⽤于异步获取执行结果或取消执行任务的场景。通过传⼊Runnable或者Callable的任务给FutureTask,直接调⽤用其run⽅法或者放入线程池执行,之后可以在外部通过FutureTask的get⽅法异步获取执行结果,因此,FutureTask⾮常适合用于耗时的计算,主线程可以在完成自⼰的任务后,再去获取结果。另外,FutureTask还可以确保即使调⽤了多次run⽅方法,它都只会执行⼀次Runnable或者Callable任务,或者通过cancel取消FutureTask的执行等。
  2. futuretask可用于执行多任务、以及避免高并发情况下多次创建数据死锁的出现。

什么是同步容器和并发容器的实现?

同步容器

  1. 主要代表有Vector和Hashtable,以及Collections.synchronizedXxx等。
  2. 锁的粒度为当前对象整体。
  3. 迭代器是快速失败的,即在迭代的过程中发现被修改,就会抛出ConcurrentModificationException。

并发容器

  1. 主要代表有ConcurrentHashMap、CopyOnWriteArrayList、ConcurrentSkipListMap、ConcurrentSkipListSet。
  2. 锁的粒度是分散的、细粒度的,即读和写是使⽤不同的锁。
  3. 迭代器具有弱一致性,即可以容忍并发修改,不会抛出ConcurrentModificationException。

ConcurrentHashMap采⽤分段锁技术,同步容器中,是⼀个容器⼀个锁,但在ConcurrentHashMap中,会将hash表的数组部分成若⼲段,每段维护⼀个锁,以达到⾼效的并发访问;

synchronized和ReentrantLock的区别?

锁的特点

  1. 可重入锁:可重入锁是指同一个线程可以多次获取同⼀把锁。ReentrantLock和synchronized都是可重入锁。
  2. 可中断锁。可中断锁是指线程尝试获取锁的过程中,是否可以响应中断。synchronized是不可中断锁,而ReentrantLock则提供了中断功能。
  3. 公平锁与非公平锁。公平锁是指多个线程同时尝试获取同⼀把锁时,获取锁的顺序按照线程达到的顺序,⽽非公平锁则允许线程“插队”。synchronized是非公平锁,而ReentrantLock的默认实现是非公平锁,但是也可以设置为公平锁。
  4. CAS操作(CompareAndSwap):CAS操作简单的说就是⽐较并交换。CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在 CAS 指令之前返回该位置的值。CAS 有效地说明了“我认为位置 V 应该包含值 A;如果包含该值,则将 B 放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。”

Synchronized

synchronized是java内置的关键字,它提供了了一种独占的加锁方式。synchronized的获取和释放锁由JVM实现,用户不需要显示的释放锁,非常方便。然而synchronized也有一定的局限性:

ReentrantLock

Semaphore就是一个信号量,它的作用是限制某段代码块的并发数

Lock接口(Lock interface)是什么?对比同步它有什么优势?

Lock接口比同步方法和同步块提供了更具扩展性的锁操作。他们允许更灵活的结构,可以具有完全不同的性质,并且可以支持多个相关类的条件对象。

优势

ConcurrentHashMap的并发度是什么?

在JDK1.7之前:

工作机制(分片思想):它引入了一个“分段锁”的概念,具体可以理解为把⼀个大的Map拆分成N个小的segment,根据key.hashCode()来决定把key放到哪个HashTable中。可以提供相同的线程安全,但是效率提升N倍,默认提升16倍。

应用

当读>写时使用,适合做缓存,在程序启动时初始化,之后可以被多个线程访问;

hash冲突

简介:HashMap中调⽤用hashCode()方法来计算hashCode。由于在Java中两个不同的对象可能有一样的hashCode,所以不同的键可能有一样hashCode,从而导致冲突的产生。

hash冲突解决:使用红黑树来代替链表,当同一hash中的元素数量超过特定的值便会由链表切换到红黑树。

无锁读

无锁读:ConcurrentHashMap之所以有较好的并发性是因为ConcurrentHashMap是无锁读和加锁写,并且利用了分段锁(不是在所有的entry上加锁,而是在一部分entry上加锁);

并发度

ConcurrentHashMap的并发度就是segment的大小,默认为16,这意味着最多同时可以有16条线程操作ConcurrentHashMap,这也是ConcurrentHashMap对Hashtable的最大优势。

ReentrantReadWriteLock读写锁的使用?

LockSupport工具?

LockSupport是JDK中比较底层的类,用来创建锁和其他同步工具类的基本线程阻塞。java锁和同步器框架的核心 AQS: AbstractQueuedSynchronizer,就是通过调用 LockSupport .park()和 LockSupport .unpark()实现线程的阻塞和唤醒的。

wait()和sleep()的区别?

sleep()

  1. 方法是线程类(Thread)的静态⽅方法,让调用线程进入睡眠状态,让出执行机会给其他线程,等到休眠时间结束后,线程进入就绪状态和其他线程一起竞争cpu的执行时间。
  2. 因为sleep() 是static静态的方法,他不能改变对象的锁,当一个synchronized块中调用了了sleep() 方法,线程虽然进入休眠,但是对象的机锁没有被释放,其他线程依然无法访问这个对象。

wait()

  1. wait()是Object类的方法,当一个线程执行到wait方法时,它就进入到一个和该对象相关的等待池,同时释放对象的锁,使得其他线程能够访问,可以通过notify,notifyAll方法来唤醒等待的线程

如何保证多线程下 i++ 结果正确?

  1. volatile只能保证你数据的可见性,获取到的是最新的数据,不能保证原子性;
  2. 用AtomicInteger保证原⼦子性。
  3. synchronized既能保证共享变量可见性,也可以保证锁内操作的原子性。

生产者消费者模型的作用是什么?

  1. 通过平衡生产者的生产能力和消费者的消费能力来提升整个系统的运行效率,这是生产者消费者模型最重要的作用。
  2. 解耦,这是生产者消费者模型附带的作用,解耦意味着生产者和消费者之间的联系少,联系越少越可以独自发展而不需要受到相互的制约。

怎么唤醒一个阻塞的线程?

如果线程是因为调用了wait()、sleep()或者join()方法而导致的阻塞;

  1. suspend与resume Java废弃 suspend() 去挂起线程的原因,是因为 suspend() 在导致线程暂停的同时,并不会去释放任何锁资源。其他线程都无法访问被它占用的锁。直到对应的线程执行 resume() 方法后,被挂起的线程才能继续,从而其它被阻塞在这个锁的线程才可以继续执行。但是,如果 resume() 操作出现在 suspend() 之前执行,那么线程将⼀直处于挂起状态,同时一直占用锁,这就产生了死锁。而且,对于被挂起的线程,它的线程状态居然还是 Runnable。
  2. wait与notify wait与notify必须配合synchronized使用,因为调用之前必须持有锁,wait会立即释放锁,notify则是同步块执行完了才释放。
  3. await与singal Condition类提供,而Condition对象由new ReentLock().newCondition()获得,与wait和notify相同,因为使用Lock锁后无法使用wait方法。
  4. park与unpark LockSupport是一个非常方便实用的线程阻塞工具,它可以在线程任意位置让线程阻塞。和Thread.suspenf()相比,它弥补了由于resume()在前发生,导致线程无法继续执行的情况。和Object.wait()相⽐比,它不需要先获得某个对象的锁,也不会抛出IException异常。可以唤醒指定线程。

如果线程遇到了IO阻塞,无能为力,因为IO是操作系统实现的,Java代码并没有办法直接触到操作系统。

Java中用到的线程调度算法是什么

抢占式。一个线程用完CPU之后,操作系统会根据线程优先级、线程饥饿情况等数据算出一个总的优先级并分配下一个时间片给某个线程执行。

单例模式的线程安全性?

老生常谈的问题了,首先要说的是单例模式的线程安全意味着:某个类的实例在多线程环境下只会被创建一次出来。单 例模式有很多种的写法:

  1. 饿汉式单例模式的写法:线程安全
  2. 懒汉式单例模式的写法:非线程安全
  3. 双检锁单例模式的写法:线程安全

同步方法和同步块,哪个是更更好的选择?

如何检测死锁?怎么预防死锁?

什么是死锁

是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁;

死锁的四个必要条件:

  1. 互斥条件:进程对所分配到的资源不允许其他进程进行访问,若其他进程访问该资源,只能等待,直至占有该资源的进程使用完成后释放该资源
  2. 请求和保持条件:进程获得一定的资源之后,又对其他资源发出请求,但是该资源可能被其他进程占有,此时请求阻塞,但又对自己获得的资源保持不放。
  3. 不可剥夺条件:是指进程已获得的资源,在未完成使用之前,不可被剥夺,只能在使用完后自己释放
  4. 环路等待条件:是指进程发生死锁后,若干进程之间形成一种头尾相接的循环等待资源关系

死锁产生的原因

  1. 因竞争资源发生死锁现象:系统中供多个进程共享的资源的数目不足以满足全部进程的需要时,就会引起对诸资源的竞争而发生死锁现象
  2. 进程推进顺序不当发生死锁

检查死锁

死锁的解除与预防:控制不要让四个必要条件成立。

HashMap在多线程环境下使用需要注意什么?

要注意死循环的问题,HashMap的put操作引发扩容,这个动作在多线程并发下会发生线程死循环的问题。

  1. HashMap不是线程安全的;Hashtable线程安全,但效率低,因为是Hashtable是使用synchronized的,所有线程竞争同一 把锁;而ConcurrentHashMap不仅线程安全而且效率高,因为它包含一个segment数组,将数据分段存储,给每一段数据配一把锁,也就是所谓的锁分段技术。
  2. HashMap为何线程不安全:
    1. put时key相同导致其中一个线程的value被覆盖,也就是不能存储相同的key。
    2. 多个线程同时扩容,造成数据丢失;
    3. 多线程扩容时导致Node链表形成环形结构造成.next()死循环,导致CPU利利用率接近100%;

什么是守护线程?有什么用?

守护线程(即daemon thread),是个服务线程,准确地来说就是服务其他的线程,这是它的作用—而其他的线程只有一种,那就是用户线程。所以java里线程分2种,

  1. 守护线程,比如垃圾回收线程,就是最典型的守护线程。
  2. 用户线程,就是应用程序里的自定义线程。

线程池的原理

使用场景:假设一个服务器完成一项任务所需时间为:T1-创建线程时间,T2-在线程中执行任务的时间,T3-销毁线程时间。 如果T1+T3远大于T2,则可以使用线程池,以提高服务器性能;

组成

  1. 线程池管理器(ThreadPool):用于创建并管理线程池,包括创建线程池,销毁线程池,添加新任务;
  2. 工作线程(PoolWorker):线程池中线程,在没有任务时处于等待状态,可以循环的执行任务;
  3. 任务接口(Task):每个任务必须实现的接口,以供工作线程调度任务的执行,它主要规定了任务的入口,任务执行完后 的收尾工作,任务的执行状态等;
  4. 任务队列(taskQueue):用于存放没有处理的任务。提供一种缓冲机制。

原理

线程池技术正是关注如何缩短或调整T1,T3时间的技术,从而提高服务器程序性能的。它把T1,T3分别安排在服务器程序的启动和结束的时间段或者一些空闲的时间段,这样在服务器程序处理客户请求时,不会有T1,T3的开销了。

工作流程

  1. 线程池刚创建时,里面没有一个线程(也可以设置参数prestartAllCoreThreads启动预期数量主线程)。任务队列是作为参数传进来的。不过,就算队列里面有任务,线程池也不会马上执行它们。
  2. 当调用 execute() 方法添加一个任务时,线程池会做如下判断:
    1. 如果正在运行的线程数量小于 corePoolSize,那么马上创建线程运行这个任务;
    2. 如果正在运行的线程数量大于或等于 corePoolSize,那么将这个任务放入队列;
    3. 如果这时候队列满了,而且正在运行的线程数量小于 maximumPoolSize,那么还是要创建非核心线程立刻运行这个任务;
    4. 如果队列满了了,而且正在运行的线程数量大于或等于 maximumPoolSize,那么线程池会抛出异常 RejectExecutionException。
  3. 当一个线程完成任务时,它会从队列中取下一个任务来执行。
  4. 当一个线程无事可做,超过一定的时间(keepAliveTime)时,线程池会判断,如果当前运行的线程数大于corePoolSize,那么这个线程就被停掉。所以线程池的所有任务完成后,它最终会收缩到 corePoolSize 的大小。

stop() 和 suspend() 方法为何不推荐使用?

sleep() 和 wait() 有什么区别?

当一个线程进入一个对象的一个 synchronized 方法后,其它线程是否可进入此对象的其它方法?

乐观锁和悲观锁

乐观锁对应于生活中乐观的人总是想着事情往好的方向发展,悲观锁对应于生活中悲观的人总是想着事情往坏的方向发展。这两种人各有优缺点,不能不以场景而定说一种人好于另外一种人。

悲观锁

乐观锁

两种锁应用场景

从上面对两种锁的介绍,我们知道两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果是多写的情况,一般会经常产生冲突,这就会导致上层应用会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。

乐观锁常见的两种实现方式

乐观锁一般会使用版本号机制或CAS算法实现。

  1. 版本号机制

一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。

  1. CAS算法

即compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数

当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。

乐观锁存在问题

  1. ABA 问题是乐观锁一个常见的问题:

如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回A,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的 "ABA"问题。JDK 1.5 以后的 AtomicStampedReference 类就提供了此种能力,其中的 compareAndSet 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

  1. 循环开销时间开销大

自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。 如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

  1. 只能保证一个共享变量的原子操作

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。

CAS与synchronized的使用情景

简单的来说CAS适用于写比较少的情况下(多读场景,冲突一般较少),synchronized适用于写比较多的情况下(多写场景,冲突一般较多)

  1. 对于资源竞争较少(线程冲突较轻)的情况,使用synchronized同步锁进行线程阻塞和唤醒切换以及用户态内核态间的切换操作额外浪费消耗cpu资源;而CAS基于硬件实现,不需要进入内核,不需要切换线程,操作自旋几率较少,因此可以获得更高的性能。
  2. 对于资源竞争严重(线程冲突严重)的情况,CAS自旋的概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。

补充: Java并发编程这个领域中synchronized关键字一直都是元老级的角色,很久之前很多人都会称它为 “重量级锁” 。但是,在JavaSE 1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的 偏向锁 和 轻量级锁 以及其它各种优化之后变得在某些情况下并不是那么重了。synchronized的底层实现主要依靠 Lock-Free 的队列,基本思路是 自旋后阻塞,竞争切换后继续竞争锁,稍微牺牲了公平性,但获得了高吞吐量。在线程冲突较少的情况下,可以获得和CAS类似的性能;而线程冲突严重的情况下,性能远高于CAS。

线程同步和线程调度

同步方法和同步代码块的区别是什么?

区别:

线程同步以及线程调度相关的方法有哪些?

  1. wait() :使一个线程处于等待(阻塞)状态,并且释放所持有的对象的锁;
  2. sleep() :使当前线程进入指定毫秒数的休眠,暂停执行,需要处理 InterruptedException 。
  3. notify() :唤醒一个处于等待状态的线程,当然在调用此方法的时候,并不能确切的唤醒某一个等待状态的线程,而是由 JVM 确定唤醒哪个线程,而且与优先级无关。
  4. notifyAll() :唤醒所有处于等待状态的线程,该方法并不是将对象的锁给所有线程,而是让它们竞争,只有获得锁的线程才能进入就绪状态。
  5. join() :与 sleep() 方法一样,是一个可中断的方法,在一个线程中调用另一个线程的 join()方法,会使得当前的线程挂起,直到执行 join() 方法的线程结束。例如在B线程中调用A线程的join() 方法,B线程进入阻塞状态,直到A线程结束或者到达指定的时间。
  6. yield() :提醒调度器愿意放弃当前的CPU资源,使得当前线程从 RUNNING 状态切换到 RUNABLE状态

线程的sleep()方法和yield()方法有什么不同?

  1. sleep() 方法会使得当前线程暂停指定的时间,没有消耗CPU时间片。
  2. sleep() 使得线程进入到阻塞状态, yield() 只是对CPU进行提示,如果CPU没有忽略这个提示,会使得线程上下文的切换,进入到就绪状态。
  3. sleep() 一定会完成给定的休眠时间, yield() 不一定能完成。
  4. sleep() 需要抛出InterruptedException,而 yield() 方法无需抛出异常

sleep()方法和wait()方法的区别?

相同点:

不同点:

wait()方法一般在循环块中使用还是if块中使用?

在JDK官方文档中明确要求了要在循环中使用,否则可能出现虚假唤醒的可能。官方文档中给出的代码示例如下:

synchronized(obj){
  while(<condition does not hold>){
    obj.wait();
  }
//满足while中的条件后执行业务逻辑
}

如果讲 while 换成 if

synchronized(obj){
  if(<condition does not hold>){
    obj.wait();
  }
//满足if中的条件后执行业务逻辑
}

当线程被唤醒后,可能 if() 中的条件已经不满足了,出现虚假唤醒。

线程通信的方法有哪些?

为什么wait()、notify()、notifyAll()被定义在Object类中而不是在Thread类中?

为什么wait(),notify()和notifyAll()必须在同步方法或者同步块中被调用?

因为 wait() 暂停的是持有锁的对象, notify() 或 notifyAll() 唤醒的是等待锁的对象。所以wait() 、 notify() 、 notifyAll() 都需要线程持有锁的对象,进而需要在同步方法或者同步块中被调用 。

为什么Thread类的sleep()和yield()方法是静态的?

sleep() 和 yield() 都是需要正在执行的线程调用的,那些本来就阻塞或者等待的线程调用这个方法是无意义的,所以这两个方法是静态的

如何停止一个正在运行的线程?

  1. 中断: Interrupt 方法中断线程
  2. 使用 volatile boolean 标志位停止线程:在线程中设置一个 boolean 标志位,同时用 volatile修饰保证可见性,在线程里不断地读取这个值,其他地方可以修改这个 boolean 值。
  3. 使用 stop() 方法停止线程,但该方法已经被废弃。因为这样线程不能在停止前保存数据,会出现数据完整性问题。

如何唤醒一个阻塞的线程

如果线程是由于 wait() 、 sleep() 、 join() 、 yield() 等方法进入阻塞状态的,是可以进行唤醒的。如果线程是IO阻塞是无法进行唤醒的,因为IO是操作系统层面的,Java代码无法直接接触操作系统。

Java如何实现两个线程之间的通信和协作

同步方法和同步方法块哪个效果更好?

同步块更好些,因为它锁定的范围更灵活些,只在需要锁住的代码块锁住相应的对象,而同步方法会锁住整个对象

什么是线程同步?什么是线程互斥?他们是如何实现的?

线程同步的实现方法:

在Java程序中如何保证线程的运行安全?

线程安全问题 主要体现在原子性、可见性和有序性。

解决方法:

线程类的构造方法、静态块是被哪个线程调用的?

线程类的构造方法、静态块是被 new 这个线程类所在的线程所调用的,而 run() 方法里面的代码才是被线程自身所调用的。

一个很经典的例子:

一个线程运行时异常会发生什么?

Java中的 Throwable 主要分为 Exception 和 Error 。 Exception 分为运行时异常和非运行时异常。运行时异常可以不进行处理,代码也能通过编译,但

运行时会报错。非运行时异常必须处理,否则代码无法通过编译。出现Error代码会直接报错。

线程数量过多会造成什么异常?

三个线程T1、T2、T3,如何让他们按顺序执行?

这是一道面试中常考的并发编程的代码题,与它相似的问题有:

其实这类问题本质上都是线程通信问题,思路基本上都是一个线程执行完毕,阻塞该线程,唤醒其他线程,按顺序执行下一个线程。下面先来看最简单的,如何按顺序执行三个线程。

方案一:synchronized+wait/notify

基本思路就是线程A、线程B、线程C三个线程同时启动,因为变量 num 的初始值为 0 ,所以线程B或线程C拿到锁后,进入 while() 循环,然后执行 wait() 方法,线程B线程C阻塞,释放锁。只有线程A拿到锁后,不进入 while() 循环,执行 num++ ,打印字符 A ,最后唤醒线程B和线程C。此时 num 值为 1 ,只有线程B拿到锁后,不被阻塞,执行 num++ ,打印字符 B ,最后唤醒线程A和线程C,后面以此类推。

class Wait_Notify_ACB 
{
    private int num;
    private static final Object LOCK = new Object();
    private void printABC(String name, int targetNum) {
        synchronized (LOCK) {
            while (num % 3 != targetNum) { //想想这里为什么不能用if代替while,
                try {
                LOCK.wait();
                } catch (InterruptedException e) {
                e.printStackTrace();
                }
            }
            num++;
            System.out.print(name);
            LOCK.notifyAll();
        }
    }
    public static void main(String[] args) {
        Wait_Notify_ACB wait_notify_acb = new Wait_Notify_ACB ();
        new Thread(() -> {
        wait_notify_acb.printABC("A", 0);
        }, "A").start();
        new Thread(() -> {
        wait_notify_acb.printABC("B", 1);
        }, "B").start();
        new Thread(() -> {
        wait_notify_acb.printABC("C", 2);
        }, "C").start();
    }
}
//输出结果
ABC

接下来看看第一个问题,三个线程T1、T2、T3轮流打印ABC,打印n次。其实只需要将上述代码加一个循环即可,这里假设n=10。

class Wait_Notify_ACB {
    private int num;
    private static final Object LOCK = new Object();
    private void printABC(String name, int targetNum) {
        for (int i = 0; i < 10; i++) {
            synchronized (LOCK) {
            while (num % 3 != targetNum) { //想想这里为什么不能用if代替,想不起来可
                    try {
                        LOCK.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                num++;
                System.out.print(name);
                LOCK.notifyAll();
            }
        }
    }
    public static void main(String[] args) {
        Wait_Notify_ACB wait_notify_acb = new Wait_Notify_ACB ();
        new Thread(() -> {
        wait_notify_acb.printABC("A", 0);
        }, "A").start();
        
        new Thread(() -> {
        wait_notify_acb.printABC("B", 1);
        }, "B").start();
        
        new Thread(() -> {
        wait_notify_acb.printABC("C", 2);
        }, "C").start();
    }
}
//输出结果
ABCABCABCABCABCABCABCABCABCABC

下面看第二个问题,两个线程交替打印1-100的奇偶数,为了减少输入所占篇幅,这里将100 改成了10。基本思路上面类似,线程odd先拿到锁——打印数字——唤醒线程even——阻塞线程odd,以此循环。

class Wait_Notify_Odd_Even{
    private Object monitor = new Object();
    private volatile int count;
    Wait_Notify_Odd_Even(int initCount) {
        this.count = initCount;
    }
    private void printOddEven() {

        synchronized (monitor) {
            while (count < 10) {
                try {
                    System.out.print( Thread.currentThread().getName() + ":");
                    System.out.println(++count);
                    monitor.notifyAll();
                    monitor.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            //防止count=10后,while()循环不再执行,有子线程被阻塞未被唤醒,导致主线程不能退
            monitor.notifyAll();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Wait_Notify_Odd_Even waitNotifyOddEven = new Wait_Notify_Odd_Even(0);
        
        new Thread(waitNotifyOddEven::printOddEven, "odd").start();
        
        Thread.sleep(10);
        new Thread(waitNotifyOddEven::printOddEven, "even").start();
    }
}

大家都是用的synchronized+wait/notify,你能不能换个方法解决该问题?

使用join方法也可以实现

方案二:join()

join() 方法:在A线程中调用了B线程的join()方法时,表示只有当B线程执行完毕时,A线程才能继续执行。基于这个原理,我们使得三个线程按顺序执

行,然后循环多次即可。无论线程1、线程2、线程3哪个先执行,最后执行的顺序都是线程1——>线程2——>线程3。代码如下:

class Join_ABC {
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            Thread t1 = new Thread(new printABC(null),"A");
            Thread t2 = new Thread(new printABC(t1),"B");
            Thread t3 = new Thread(new printABC(t2),"C");
            t0.start();
            t1.start();
            t2.start();
            Thread.sleep(10); //这里是要保证只有t1、t2、t3为一组,进行执行才能保证t1->t2->t3的执行顺序。
        }
    }
    static class printABC implements Runnable{
        private Thread beforeThread;
        public printABC(Thread beforeThread) {
            this.beforeThread = beforeThread;
        }
        @Override
        public void run() {
            if(beforeThread!=null) {
                try {
                    beforeThread.join();
                    System.out.print(Thread.currentThread().getName());
                }catch(Exception e){
                    e.printStackTrace();
                }
            }else {
                System.out.print(Thread.currentThread().getName());
            }
        }
    }   
}
//结果
ABCABCABCABCABCABCABCABCABCABC

方案三:Lock

该方法很容易理解,其实现代码和synchronized+wait/notify方法的很像。不管哪个线程拿到锁,只有符合条件的才能打印。代码如下 :

class Lock_ABC {
    private int num; // 当前状态值:保证三个线程之间交替打印
    private Lock lock = new ReentrantLock();
    private void printABC(String name, int targetNum) {
        for (int i = 0; i < 10; ) {
            lock.lock();
            if (num % 3 == targetNum) {
                num++;
                i++;
                System.out.print(name);
            }
            lock.unlock();
        }
    }
    public static void main(String[] args) {
        Lock_ABC lockABC = new Lock_ABC();
        new Thread(() -> {
        lockABC.printABC("A", 0);
        }, "A").start();
        new Thread(() -> {
        lockABC.printABC("B", 1);
        }, "B").start();
        new Thread(() -> {
        lockABC.printABC("C", 2);
        }, "C").start();
    }
}
//结果
ABCABCABCABCABCABCABCABCABCABC

该方法还可以使用Lock+Condition实现对线程的精准唤醒,减少对其他线程无意义地唤醒,浪费资源。

方案四:Lock+Condition

该思路和synchronized+wait/notify方法的更像了,synchronized对应lock,await/signal方法对应wait/notify方法。下面的代码为了能精准地唤醒下一个线程,创建了多个Condition对象。

class LockConditionABC {
    private int num;
    private static Lock lock = new ReentrantLock();
    private static Condition c1 = lock.newCondition();
    private static Condition c2 = lock.newCondition();
    private static Condition c3 = lock.newCondition();
    private void printABC(String name, int targetNum, Condition currentThread,Condition nextThread) {

        for (int i = 0; i < 10; ) {
            lock.lock();
            try {
                while (num % 3 != targetNum) {
                    currentThread.await();
                }
                num++;
                i++;
                System.out.print(name);
                nextThread.signal();
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }
    }
    public static void main(String[] args) {
        LockConditionABC print = new LockConditionABC();
        new Thread(() -> {
            print.printABC("A", 0, c1, c2);
        }, "A").start();
        new Thread(() -> {
            print.printABC("B", 1, c2, c3);
        }, "B").start();
        new Thread(() -> {
            print.printABC("C", 2, c3, c1);
        }, "C").start();
    }
}

方案五:信号量Semaphore

class SemaphoreABC {
    private static Semaphore s1 = new Semaphore(1); //先打印A,所以设s1中的计数器值为1
    private static Semaphore s2 = new Semaphore(0);
    private static Semaphore s3 = new Semaphore(0);
    private void printABC(String name, Semaphore currentThread, Semaphore nextThread) {
        for (int i = 0; i < 10; i++) {
            try {
                currentThread.acquire(); //阻塞当前线程,即调用当前线程acquire(),计数器减1为0
                System.out.print(name);
                nextThread.release(); //唤醒下一个线程,即调用下一个线程线程
                release()//计数器加1
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        SemaphoreABC printer = new SemaphoreABC();
        new Thread(() -> {
        printer.printABC("A", s1, s2);
        }, "A").start();
        
        Thread.sleep(10);
        new Thread(() -> {
        printer.printABC("B", s2, s3);
        }, "B").start();
        
        Thread.sleep(10);
        new Thread(() -> {
        printer.printABC("C", s3, s1);
        }, "C").start();
    }
}

除了上面的方法,还有LockSupport、CountDownLatch、AtomicInteger等等也可以实现。

synchronized关键字

什么是synchronized关键字 ?

在多线程的环境下,多个线程同时访问共享资源会出现一些问题,而synchronized关键字则是用来保证线程同步的 。

Synchronized 是由 JVM 实现的一种实现互斥同步的一种方式,如果你查看被 Synchronized 修饰过的程序块编译后的字节码,会发现,被 Synchronized 修饰过的程序块,在编译前后被编译器生成了 monitorenter 和 monitorexit 两个字节码指令。

这两个指令是什么意思呢?

这个“锁”到底是什么?如何确定对象的锁?

“锁”的本质其实是 monitorenter 和 monitorexit 字节码指令的一个 Reference 类型的参数,即要锁定和解锁的对象。我们知道,使用Synchronized 可以修饰不同的对象,因此,对应的对象锁可以这么确定。

  1. 如果 Synchronized 明确指定了锁对象,比如 Synchronized(变量名)、Synchronized(this) 等,说明加解锁对象为该对象。
  2. 如果没有明确指定:
    1. 若 Synchronized 修饰的方法为非静态方法,表示此方法对应的对象为锁对象;
    2. 若 Synchronized 修饰的方法为静态方法,则表示此方法对应的类对象为锁对象。

注意,当一个对象被锁住时,对象里面所有用 Synchronized 修饰的方法都将产生堵塞,而对象里非 Synchronized 修饰的方法可正常被调用,不受锁影响。

如果同步块内的线程抛出异常会发生什么?

synchronized方法正常返回或者抛异常而终止,JVM会自动释放对象锁

Java内存的可见性问题

在了解synchronized关键字的底层原理前,需要先简单了解下Java的内存模型,看看synchronized关键字是如何起作用的。

1631518540214
1631518540214

这里的本地内存并不是真实存在的,只是Java内存模型的一个抽象概念,它包含了控制器、运算器、缓存等。同时Java内存模型规定,线程对共享变量

的操作必须在自己的本地内存中进行,不能直接在主内存中操作共享变量。这种内存模型会出现什么问题呢?

  1. 线程A获取到共享变量X的值,此时本地内存A中没有X的值,所以加载主内存中的X值并缓存到本地内存A中,线程A修改X的值为1,并将X的值刷

    新到主内存中,这时主内存及本地内存中的X的值都为1。

  2. 线程B需要获取共享变量X的值,此时本地内存B中没有X的值,加载主内存中的X值并缓存到本地内存B中,此时X的值为1。线程B修改X的值为2,

并刷新到主内存中,此时主内存及本地内存B中的X值为2,本地内存A中的X值为1。

  1. 线程A再次获取共享变量X的值,此时本地内存中存在X的值,所以直接从本地内存中A获取到了X为1的值,但此时主内存中X的值为2,到此出现了

所谓内存不可见的问题。

该问题Java内存模型是通过synchronized关键字和volatile关键字就可以解决,那么synchronized关键字是如何解决的呢,其实进入synchronized块就是

把在synchronized块内使用到的变量从线程的本地内存中擦除,这样在synchronized块中再次使用到该变量就不能从本地内存中获取了,需要从主内存

中获取,解决了内存不可见问题 。

synchronized关键字三大特性是什么?

面试时经常拿synchronized关键字和volatile关键字的特性进行对比,synchronized关键字可以保证并发编程的三大特性:原子性、可见性、有序性,而volatile关键字只能保证可见性和有序性,不能保证原子性,也称为是轻量级的synchronized

synchronized关键字可以实现什么类型的锁?

synchronized关键字的使用方式

synchronized主要有三种使用方式:修饰普通同步方法、修饰静态同步方法、修饰同步方法块。

修饰普通同步方法(实例方法)

class syncTest implements Runnable {
    private static int i = 0; //共享资源
    private synchronized void add() {
        i++;
    }
    @Override
    public void run() {
        for (int j = 0; j < 10000; j++) {
            add();
        }
    }
    public static void main(String[] args) throws Exception {
        syncTest syncTest = new syncTest();
        Thread t1 = new Thread(syncTest);
        Thread t2 = new Thread(syncTest);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
}

这是一个非常经典的例子,多个线程操作 i++ 会出现线程不安全问题,这段代码的结果很容易得到 ,结果是2000。

大家可以再看看这段代码,猜一猜它的运行结果 :

class syncTest implements Runnable {
    private static int i = 0; //共享资源
    private synchronized void add() {
        i++;
    }
    @Override
    public void run() {
        for (int j = 0; j < 10000; j++) {
            add();
        }
    }
    public static void main(String[] args) throws Exception {
        // syncTest syncTest = new syncTest();
        Thread t1 = new Thread(new syncTest());
        Thread t2 = new Thread(new syncTest());
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
}
//结果不确定

第二个示例中的 add() 方法虽然也使用synchronized关键字修饰了,但是因为两次 new syncTest() 操作建立的是两个不同的对象,也就是说存在两个不

同的对象锁,线程t1和t2使用的是不同的对象锁,所以不能保证线程安全。那这种情况应该如何解决呢?因为每次创建的实例对象都是不同的,而类对

象却只有一个,如果synchronized关键字作用于类对象,即用synchronized修饰静态方法,问题则迎刃而解。

修饰静态方法

只需要在 add() 方法前用static修饰即可,即当synchronized作用于静态方法,锁就是当前的class对象。

class syncTest implements Runnable {
    private static int i = 0; //共享资源
    private static synchronized void add() {//锁住的是类的方法
        i++;
    }
    @Override
    public void run() {
        for (int j = 0; j < 10000; j++) {
            add();
        }
    }
    public static void main(String[] args) throws Exception {
        // syncTest syncTest = new syncTest();
        Thread t1 = new Thread(new syncTest());
        Thread t2 = new Thread(new syncTest());
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
}
//结果是2000

修饰代码块

如果某些情况下,整个方法体比较大,需要同步的代码只是一小部分,如果直接对整个方法体进行同步,会使得代码性能变差,这时只需要对一小部分代码进行同步即可。代码如下:

class syncTest implements Runnable {
    static int i = 0; //共享资源
    @Override
    public void run() {
        //其他操作.......
        synchronized (this){ //this表示当前对象实例,这里还可以使用syncTest.class,表示class对象锁
            for (int j = 0; j < 10000; j++) {
                i++;
            }
        }
    }
    public static void main(String[] args) throws Exception {
        syncTest syncTest = new syncTest();
        Thread t1 = new Thread(syncTest);
        Thread t2 = new Thread(syncTest);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
}
//结果
2000

synchronized底层原理

这个问题也是面试比较高频的一个问题,也是比较难理解的,理解synchronized需要一定的Java虚拟机的知识

在jdk1.6之前,synchronized被称为重量锁,在jdk1.6中,为了减少获得锁和释放锁带来的性能开销,引入了偏向锁和轻量级锁。下面先介绍jdk1.6之前

的synchronized原理。

对象头

在HotSpot虚拟机中,Java对象在内存中的布局大致可以分为三部分:对象头、实例数据和填充对齐。因为synchronized用的锁是存在对象头里的,所

以我们需要重点了解对象头。如果对象头是数组类型,则对象头由Mark Word、Class MetadataAddress和Array length组成,如果对象头非数组类型,

对象头则由Mark Word和Class MetadataAddress组成。在32位虚拟机中,数组类型的Java对象头的组成如下表:

1631521620111
1631521620111

这里我们需要重点掌握的是Mark Word。

Mark Word

在运行期间,Mark Word中存储的数据会随着锁标志位的变化而变化,在32位虚拟机中,不同状态下的组成如下:

1631521678744
1631521678744

其中线程ID表示持有偏向锁线程的ID,Epoch表示偏向锁的时间戳,偏向锁和轻量级锁是在jdk1.6中引入的

重量级锁的底部实现原理:Monitor

在jdk1.6之前,synchronized只能实现重量级锁,Java虚拟机是基于Monitor对象来实现重量级锁的,所以首先来了解下Monitor,在Hotspot虚拟机中,

Monitor是由ObjectMonitor实现的,其源码是用C++语言编写的 ,首先我们先下载Hotspot的源码,源码下载链接:http://hg.openjdk.java.net/jdk8/jd

k8/hotspot,找到ObjectMonitor.hpp文件,路径是 src/share/vm/runtime/objectMonitor.hpp ,这里只是简单介绍下其数据结构 :

1631521884865
1631521884865

其中 _owner_WaitSet_EntryList字段比较重要,它们之间的转换关系如下图

1631521941833
1631521941833

从上图可以总结获取Monitor和释放Monitor的流程如下:

  1. 当多个线程同时访问同步代码块时,首先会进入到EntryList中,然后通过CAS的方式尝试将Monitor中的owner字段设置为当前线程,同时count加

1,若发现之前的owner的值就是指向当前线程的,recursions也需要加1。如果CAS尝试获取锁失败,则进入到EntryList中。

  1. 当获取锁的线程调用 wait() 方法,则会将owner设置为null,同时count减1,recursions减1,当前线程加入到WaitSet中,等待被唤醒 。
  2. 当前线程执行完同步代码块时,则会释放锁,count减1,recursions减1。当recursions的值为0时,说明线程已经释放了锁

之前提到过一个常见面试题,为什么 wait() 、 notify() 等方法要在同步方法或同步代码块中来执行呢,这里就能找到原因,是因为 wait() 、 notify() 方法需要借助ObjectMonitor对象内部方法来完成。

synchronized作用于同步代码块的实现原理

前面已经了解Monitor的实现细节,而Java虚拟机则是通过进入和退出Monitor对象来实现方法同步和代码块同步的。这里为了更方便看程序字节码执行

指令,我先在IDEA中安装了一个 jclasslib Bytecodeviewer 插件。我们先来看这个synchronized作用于同步代码块的代码。

public void run() {
    //其他操作.......
    synchronized (this){ //this表示当前对象实例,这里还可以使用syncTest.class,表
        示class对象锁
        for (int j = 0; j < 10000; j++) {
            i++;
        }
    }
}

查看字节码如下:

1 dup
2 astore_1
3 monitorenter //进入同步代码块的指令
4 iconst_0
5 istore_2
6 iload_2
7 sipush 10000
10 if_icmpge 27 (+17)
13 getstatic #2 <com/company/syncTest.i>
16 iconst_1
17 iadd
18 putstatic #2 <com/company/syncTest.i>
21 iinc 2 by 1
24 goto 6 (-18)
27 aload_1
28 monitorexit //结束同步代码块的指令
29 goto 37 (+8)
32 astore_3
33 aload_1
34 monitorexit //遇到异常时执行的指令
35 aload_3
36 athrow
37 return

从上述字节码中可以看到同步代码块的实现是由monitorenter 和 monitorexit 指令完成的,其中monitorenter指令所在的位置是同步代码块开始的位置,

第一个monitorexit 指令是用于正常结束同步代码块的指令,第二个monitorexit 指令是用于异常结束时所执行的释放Monitor指令。

synchronized作用于同步方法原理

private synchronized void add() {
    i++;
}

查看字节码:

0 getstatic #2 <com/company/syncTest.i>
3 iconst_1
4 iadd
5 putstatic #2 <com/company/syncTest.i>
8 return

发现这个没有monitorenter 和 monitorexit 这两个指令了,而在查看该方法的class文件的结构信息时发现了Access flags后边的synchronized标识,该标

识表明了该方法是一个同步方法。Java虚拟机通过该标识可以来辨别一个方法是否为同步方法,如果有该标识,线程将持有Monitor,在执行方法,最

后释放Monitor。

1631522370064
1631522370064

原理大概就是这样,最后总结一下,面试中应该简洁地如何回答synchroized的底层原理这个问题。

答:Java虚拟机是通过进入和退出Monitor对象来实现代码块同步和方法同步的,代码块同步使用的是monitorenter 和 monitorexit 指令实现的,而

方法同步是通过Access flags后面的标识来确定该方法是否为同步方法。

Jdk1.6为什么要对synchronized进行优化?

因为Java虚拟机是通过进入和退出Monitor对象来实现代码块同步和方法同步的,而Monitor是依靠底层操作系统的Mutex Lock来实现的,操作系统实现

线程之间的切换需要从用户态转换到内核态,这个切换成本比较高,对性能影响较大。

jDK1.6对synchronized做了哪些优化?

在 Java 6 之前,Monitor 的实现完全依赖底层操作系统的互斥锁来实现,也就是我们刚才在问题二中所阐述的获取/释放锁的逻辑。由于 Java 层面的线程与操作系统的原生线程有映射关系,如果要将一个线程进行阻塞或唤起都需要操作系统的协助,这就需要从用户态切换到内核态来执行,这种切换代价十分昂贵,很耗处理器时间,现代 JDK 中做了大量的优化。

锁的升级

在JDK1.6中,为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁,锁的状态变成了四种,无锁状态,偏向锁状态、轻量级锁状态和重量级锁状态。锁的状态会随着竞争激烈逐渐升级,但通常情况下,锁的状态只能升级不能降级 。

另一种优化是使用自旋锁,即在把线程进行阻塞操作之前先让线程自旋等待一段时间,可能在等待期间其他线程已经解锁,这时就无需再让线程执行阻塞操作,避免了用户态到内核态的切换。

现代 JDK 中还提供了四种不同的 Monitor 实现,也就是四种不同的锁:

这四种锁使得 JDK 得以优化 Synchronized 的运行,当 JVM 检测到不同的竞争状况时,会自动切换到适合的锁实现,这就是锁的升级、降级。

为什么说 Synchronized 是非公平锁?

非公平主要表现在获取锁的行为上,并非是按照申请锁的时间前后给等待线程分配锁的,每当锁被释放后,任何一个线程都有机会竞争到锁,这样做的目的是为了提高执行性能,缺点是可能会产生线程饥饿现象。

什么是锁消除和锁粗化?

锁粗化就是增大锁的作用域。

为什么说 Synchronized 是一个悲观锁?乐观锁的实现原理又是什么?什么是 CAS,它有什么特性?

Synchronized 显然是一个悲观锁,因为它的并发策略是悲观的:

随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略。先进行操作,如果没有其他线程征用数据,那操作就成功了;如果共享数据有征用,产生了冲突,那就再进行其他的补偿措施。这种乐观的并发策略的许多实现不需要线程挂起,所以被称为非阻塞同步。

CAS 具有原子性,它的原子性由 CPU 硬件指令实现保证,即使用 JNI 调用 Native 方法调用由 C++ 编写的硬件级别指令,JDK 中提供了 Unsafe 类执行这些操作。

乐观锁一定就是好的吗?

乐观锁避免了悲观锁独占对象的现象,同时也提高了并发性能,但它也有缺点:

  1. 乐观锁只能保证一个共享变量的原子操作。如果多一个或几个变量,乐观锁将变得力不从心,但互斥锁能轻易解决,不管对象数量多少及对象颗粒度大小。
  2. 长时间自旋可能导致开销大。假如 CAS 长时间不成功而一直自旋,会给 CPU 带来很大的开销。
  3. ABA 问题。CAS 的核心思想是通过比对内存值与预期值是否一样而判断内存值是否被改过,但这个判断逻辑不严谨,假如内存值原来是 A,后来被一条线程改为 B,最后又被改成了 A,则 CAS 认为此内存值并没有发生改变,但实际上是有被其他线程改过的,这种情况对依赖过程值的情景的运算结果影响很大。解决的思路是引入版本号,每次变量更新都把版本号加一。

Java中都有哪几种锁

乐观锁和悲观锁

悲观锁

悲观锁对应于生活中悲观的人,悲观的人总是想着事情往坏的方向发展。

举个生活中的例子,假设厕所只有一个坑位了,悲观锁上厕所会第一时间把门反锁上,这样其他人上厕所只能在门外等候,这种状态就是「阻塞」了。

回到代码世界中,一个共享数据加了悲观锁,那线程每次想操作这个数据前都会假设其他线程也可能会操作这个数据,所以每次操作前都会上锁,这样其他线程想操作这个数据拿不到锁只能阻塞了。

1631530330224
1631530330224

在 Java 语言中 synchronizedReentrantLock等就是典型的悲观锁,还有一些使用了 synchronized 关键字的容器类如 HashTable 等也是悲观锁的应用。

乐观锁

乐观锁 对应于生活中乐观的人,乐观的人总是想着事情往好的方向发展。

举个生活中的例子,假设厕所只有一个坑位了,乐观锁认为:这荒郊野外的,又没有什么人,不会有人抢我坑位的,每次关门上锁多浪费时间,还是不加锁好了。你看乐观锁就是天生乐观!

回到代码世界中,乐观锁操作数据时不会上锁,在更新的时候会判断一下在此期间是否有其他线程去更新这个数据。

1631530383507
1631530383507

乐观锁可以使用版本号机制CAS算法实现。在 Java 语言中 java.util.concurrent.atomic包下的原子类就是使用CAS 乐观锁实现的。

两种锁的使用场景

独占锁和共享锁

独占锁

独占锁是指锁一次只能被一个线程所持有。如果一个线程对数据加上排他锁后,那么其他线程不能再对该数据加任何类型的锁。获得独占锁的线程即能读数据又能修改数据。

1631530486766
1631530486766

JDK中的synchronizedjava.util.concurrent(JUC)包中Lock的实现类就是独占锁。

共享锁

共享锁是指锁可被多个线程所持有。如果一个线程对数据加上共享锁后,那么其他线程只能对数据再加共享锁,不能加独占锁。获得共享锁的线程只能读数据,不能修改数据。

1631530535776
1631530535776

在 JDK 中 ReentrantReadWriteLock 就是一种共享锁。

互斥锁和读写锁

互斥锁

互斥锁是独占锁的一种常规实现,是指某一资源同时只允许一个访问者对其进行访问,具有唯一性和排它性。

1631530587865
1631530587865

互斥锁一次只能一个线程拥有互斥锁,其他线程只有等待。

读写锁

读写锁是共享锁的一种具体实现。读写锁管理一组锁,一个是只读的锁,一个是写锁。

读锁可以在没有写锁的时候被多个线程同时持有,而写锁是独占的。写锁的优先级要高于读锁,一个获得了读锁的线程必须能看到前一个释放的写锁所更新的内容。

读写锁相比于互斥锁并发程度更高,每次只有一个写线程,但是同时可以有多个线程并发读。

1631530636566
1631530636566

在 JDK 中定义了一个读写锁的接口:ReadWriteLock,ReentrantReadWriteLock 实现了ReadWriteLock接口

公平锁和非公平锁

公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁,这里类似排队买票,先来的人先买,后来的人在队尾排着,这是公平的。

1631530707557
1631530707557

在 java 中可以通过构造函数初始化公平锁

/**
* 创建一个可重入锁,true 表示公平锁,false 表示非公平锁。默认非公平锁
*/
Lock lock = new ReentrantLock(true);

非公平锁

非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁,在高并发环境下,有可能造成优先级翻转,或者饥饿的状态(某个线程一直得不到锁)。

1631530756054
1631530756054

在 java 中 synchronized 关键字是非公平锁,ReentrantLock默认也是非公平锁。

/**
* 创建一个可重入锁,true 表示公平锁,false 表示非公平锁。默认非公平锁
*/
Lock lock = new ReentrantLock(false);

可重入锁

可重入锁又称之为递归锁,是指同一个线程在外层方法获取了锁,在进入内层方法会自动获取锁。

1631530826317
1631530826317

对于Java ReentrantLock而言, 他的名字就可以看出是一个可重入锁。对于Synchronized而言,也是一个可重入锁。

可重入锁的一个好处是可一定程度避免死锁。

以 synchronized 为例,看一下下面的代码:

public synchronized void mehtodA() throws Exception{
 // Do some magic tings
 mehtodB();
}

public synchronized void mehtodB() throws Exception{
 // Do some magic tings
}

上面的代码中 methodA 调用 methodB,如果一个线程调用methodA 已经获取了锁再去调用 methodB 就不需要再次获取锁了,这就是可重入锁的特性。如果不是可重入锁的话,mehtodB 可能不会被当前线程执行,可能造成死锁。

自旋锁

自旋锁是指线程在没有获得锁时不是被直接挂起,而是执行一个忙循环,这个忙循环就是所谓的自旋。

1631530931219
1631530931219

自旋锁的目的是为了减少线程被挂起的几率,因为线程的挂起和唤醒也都是耗资源的操作。

如果锁被另一个线程占用的时间比较长,即使自旋了之后当前线程还是会被挂起,忙循环就会变成浪费系统资源的操作,反而降低了整体性能。因此自旋锁是不适应锁占用时间长的并发情况的。

在 Java 中,AtomicInteger 类有自旋的操作,我们看一下代码:

public final int getAndAddInt(Object o, long offset, int delta) {
    int v;
    do {
        v = getIntVolatile(o, offset);
    } while (!compareAndSwapInt(o, offset, v, v + delta));
    return v;
}

CAS 操作如果失败就会一直循环获取当前 value 值然后重试。

另外自适应自旋锁也需要了解一下。

在JDK1.6又引入了自适应自旋,这个就比较智能了,自旋时间不再固定,由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定。如果虚拟机认为这次自旋也很有可能再次成功那就会次序较多的时间,如果自旋很少成功,那以后可能就直接省略掉自旋过程,避免浪费处理器资源。

分段锁

分段锁 是一种锁的设计,并不是具体的一种锁。分段锁设计目的是将锁的粒度进一步细化,当操作不需要更新整个数组的时候,就仅仅针对数组中的一项进行加锁操作。

在 Java 语言中 CurrentHashMap 底层就用了分段锁,使用Segment,就可以进行并发使用了。

锁升级(无锁|偏向锁|轻量级锁|重量级锁)

JDK1.6 为了提升性能减少获得锁和释放锁所带来的消耗,引入了4种锁的状态:无锁偏向锁轻量级锁重量级锁,它会随着多线程的竞争情况逐渐升级,但不能降级。

无锁

无锁状态其实就是上面讲的乐观锁,这里不再赘述。

偏向锁

Java偏向锁(Biased Locking)是指它会偏向于第一个访问锁的线程,如果在运行过程中,只有一个线程访问加锁的资源,不存在多线程竞争的情况,那么线程是不需要重复获取锁的,这种情况下,就会给线程加一个偏向锁。

偏向锁的实现是通过控制对象Mark Word的标志位来实现的,如果当前是可偏向状态,需要进一步判断对象头存储的线程 ID 是否与当前线程 ID 一致,如果一致直接进入。

轻量级锁

当线程竞争变得比较激烈时,偏向锁就会升级为轻量级锁,轻量级锁认为虽然竞争是存在的,但是理想情况下竞争的程度很低,通过自旋方式等待上一个线程释放锁。

重量级锁

如果线程并发进一步加剧,线程的自旋超过了一定次数,或者一个线程持有锁,一个线程在自旋,又来了第三个线程访问时(反正就是竞争继续加大了),轻量级锁就会膨胀为重量级锁,重量级锁会使除了此时拥有锁的线程以外的线程都阻塞。

升级到重量级锁其实就是互斥锁了,一个线程拿到锁,其余线程都会处于阻塞等待状态。

在 Java 中,synchronized 关键字内部实现原理就是锁升级的过程:无锁 --> 偏向锁 --> 轻量级锁 --> 重量级锁。这一过程在后续讲解 synchronized 关键字的原理时会详细介绍。

锁优化技术(锁粗化、锁消除)

锁粗化

锁粗化就是将多个同步块的数量减少,并将单个同步块的作用范围扩大,本质上就是将多次上锁、解锁的请求合并为一次同步请求。

举个例子,一个循环体中有一个代码同步块,每次循环都会执行加锁解锁操作。

private static final Object LOCK = new Object();

for(int i = 0;i < 100; i++) {
    synchronized(LOCK){
        // do some magic things
    }
}

经过锁粗化后就变成下面这个样子了:

 synchronized(LOCK){
     for(int i = 0;i < 100; i++) {
        // do some magic things
    }
}

锁消除

锁消除是指虚拟机编译器在运行时检测到了共享数据没有竞争的锁,从而将这些锁进行消除。

举个例子让大家更好理解。

public String test(String s1, String s2){
    StringBuffer stringBuffer = new StringBuffer();
    stringBuffer.append(s1);
    stringBuffer.append(s2);
    return stringBuffer.toString();
}

上面代码中有一个 test 方法,主要作用是将字符串 s1 和字符串 s2 串联起来。

test 方法中三个变量s1, s2, stringBuffer, 它们都是局部变量,局部变量是在栈上的,栈是线程私有的,所以就算有多个线程访问 test 方法也是线程安全的。

我们都知道 StringBuffer 是线程安全的类,append 方法是同步方法,但是 test 方法本来就是线程安全的,为了提升效率,虚拟机帮我们消除了这些同步锁,这个过程就被称为锁消除

StringBuffer.class

// append 是同步方法
public synchronized StringBuffer append(String str) {
    toStringCache = null;
    super.append(str);
    return this;
}

总结

1631531251684
1631531251684

重入锁 ReentrantLock 及其他显式锁相关问题

跟 Synchronized 相比,可重入锁 ReentrantLock 其实现原理有什么不同?

其实,锁的实现原理基本是为了达到一个目的:让所有的线程都能看到某种标记。

那么请谈谈 AQS 框架是怎么回事儿?

AQS(AbstractQueuedSynchronizer 类)是一个用来构建锁和同步器的框架,各种 Lock 包中的锁(常用的有 ReentrantLock、ReadWriteLock),以及其他如 Semaphore、CountDownLatch,甚至是早期的 FutureTask 等,都是基于 AQS 来构建。

  1. AQS 在内部定义了一个 volatile int state 变量,表示同步状态:当线程调用 lock 方法时 ,如果 state=0,说明没有任何线程占有共享资源的锁,可以获得锁并将 state=1;如果 state=1,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列进行等待。
  2. AQS 通过 Node 内部类构成的一个双向链表结构的同步队列,来完成线程获取锁的排队工作,当有线程获取锁失败后,就被添加到队列末尾。
    1. Node 类是对要访问同步代码的线程的封装,包含了线程本身及其状态叫 waitStatus(有五种不同 取值,分别表示是否被阻塞,是否等待唤醒,是否已经被取消等),每个 Node 结点关联其 prev 结点和 next 结点,方便线程释放锁后快速唤醒下一个在等待的线程,是一个 FIFO 的过程。
    2. Node 类有两个常量,SHARED 和 EXCLUSIVE,分别代表共享模式和独占模式。所谓共享模式是一个锁允许多条线程同时操作(信号量 Semaphore 就是基于 AQS 的共享模式实现的),独占模式是同一个时间段只能有一个线程对共享资源进行操作,多余的请求线程需要排队等待(如 ReentranLock)。
  3. AQS 通过内部类 ConditionObject 构建等待队列(可有多个),当 Condition 调用 wait() 方法后,线程将会加入等待队列中,而当Condition 调用 signal() 方法后,线程将从等待队列转移动同步队列中进行锁竞争。
  4. AQS 和 Condition 各自维护了不同的队列,在使用 Lock 和 Condition 的时候,其实就是两个队列的互相移动。

请尽可能详尽地对比下 Synchronized 和 ReentrantLock 的异同。

ReentrantLock 是 Lock 的实现类,是一个互斥的同步锁。从功能角度,ReentrantLock 比 Synchronized 的同步操作更精细(因为可以像普通对象一样使用),甚至实现 Synchronized 没有的高级功能,如:

ReentrantLock 是如何实现可重入性的?

ReentrantLock 内部自定义了同步器 Sync(Sync 既实现了 AQS,又实现了 AOS,而 AOS 提供了一种互斥锁持有的方式),其实就是加锁的时候通过 CAS 算法,将线程对象放到一个双向链表中,每次获取锁的时候,看下当前维护的那个线程 ID 和当前请求的线程 ID 是否一样,一样就可重入了。

除了 ReetrantLock,你还接触过 JUC 中的哪些并发工具?

通常所说的并发包(JUC)也就是 java.util.concurrent 及其子包,集中了 Java 并发的各种基础工具类,具体主要包括几个方面:

  1. 提供了 CountDownLatch、CyclicBarrier、Semaphore 等,比 Synchronized 更加高级,可以实现更加丰富多线程操作的同步结构。
  2. 提供了 ConcurrentHashMap、有序的 ConcunrrentSkipListMap,或者通过类似快照机制实现线程安全的动态数组 CopyOnWriteArrayList 等,各种线程安全的容器。
  3. 提供了 ArrayBlockingQueue、SynchorousQueue 或针对特定场景的 PriorityBlockingQueue 等,各种并发队列实现。
  4. 强大的 Executor 框架,可以创建各种不同类型的线程池,调度任务运行等。

请谈谈 ReadWriteLock 和 StampedLock。

虽然 ReentrantLock 和 Synchronized 简单实用,但是行为上有一定局限性,要么不占,要么独占。实际应用场景中,有时候不需要大量竞争的写操作,而是以并发读取为主,为了进一步优化并发操作的粒度,Java 提供了读写锁。

读写锁基于的原理是多个读操作不需要互斥,如果读锁试图锁定时,写锁是被某个线程持有,读锁将无法获得,而只好等待对方操作结束,这样就可以自动保证不会读取到有争议的数据。

读写锁看起来比 Synchronized 的粒度似乎细一些,但在实际应用中,其表现也并不尽如人意,主要还是因为相对比较大的开销。所以,JDK 在后期引入了 StampedLock,在提供类似读写锁的同时,还支持优化读模式。优化读基于假设,大多数情况下读操作并不会和写操作冲突,其逻辑是先试着修改,然后通过 validate 方法确认是否进入了写模式,如果没有进入,就成功避免了开销;如果进入,则尝试获取读锁。

如何让 Java 的线程彼此同步?你了解过哪些同步器?请分别介绍下

JUC 中的同步器三个主要的成员:CountDownLatch、CyclicBarrier 和 Semaphore,通过它们可以方便地实现很多线程之间协作的功能。

CountDownLatch 叫倒计数,允许一个或多个线程等待某些操作完成。看几个场景:

用法:CountDownLatch 构造方法指明计数数量,被等待线程调用 countDown 将计数器减 1,等待线程使用 await 进行线程等待。

CyclicBarrier 叫循环栅栏,它实现让一组线程等待至某个状态之后再全部同时执行,而且当所有等待线程被释放后,CyclicBarrier 可以被重复使用。CyclicBarrier 的典型应用场景是用来等待并发线程结束。

Semaphore,Java 版本的信号量实现,用于控制同时访问的线程个数,来达到限制通用资源访问的目的,其原理是通过 acquire() 获取一个许可,如果没有就等待,而 release() 释放一个许可。

CyclicBarrier 和 CountDownLatch 看起来很相似,请对比下

它们的行为有一定相似度,区别主要在于:

  1. CountDownLatch 是不可以重置的,所以无法重用,CyclicBarrier 没有这种限制,可以重用。
  2. CountDownLatch 的基本操作组合是 countDown/await,调用 await 的线程阻塞等待 countDown 足够的次数,不管你是在一个线程还是多个线程里 countDown,只要次数足够即可。 CyclicBarrier 的基本操作组合就是 await,当所有的伙伴都调用了 await,才会继续进行任务,并自动进行重置。
  3. CountDownLatch 目的是让一个线程等待其他 N 个线程达到某个条件后,自己再去做某个事(通过 CyclicBarrier 的第二个构造方法 public CyclicBarrier(int parties, Runnable barrierAction),在新线程里做事可以达到同样的效果)。而 CyclicBarrier 的目的是让 N 多线程互相等待直到所有的都达到某个状态,然后这 N 个线程再继续执行各自后续(通过 CountDownLatch 在某些场合也能完成类似的效果)。
1632049388807
1632049388807

volatile

什么是 Java 的内存模型,Java 中各个线程是怎么彼此看到对方的变量的?

Java 的内存模型定义了程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出这样的底层细节。 此处的变量包括实例字段、静态字段和构成数组对象的元素,但是不包括局部变量和方法参数,因为这些是线程私有的,不会被共享,所以不存在竞争问题。

Java 中各个线程是怎么彼此看到对方的变量的呢?Java 中定义了主内存与工作内存的概念:

请谈谈 volatile 有什么特点,为什么它能保证变量对所有线程的可见性?

关键字 volatile 是 Java 虚拟机提供的最轻量级的同步机制。当一个变量被定义成 volatile 之后,具备两种特性:

  1. 保证此变量对所有线程的可见性。当一条线程修改了这个变量的值,新值对于其他线程是可以立即得知的。而普通变量做不到这一点。
  2. 禁止指令重排序优化。普通变量仅仅能保证在该方法执行过程中,得到正确结果,但是不保证程序代码的执行顺序。

Java 的内存模型定义了 8 种内存间操作:

lock 和 unlock

read 和 write

load 和 store

use 和 assgin

volatile 的实现基于这 8 种内存间操作,保证了一个线程对某个 volatile 变量的修改,一定会被另一个线程看见,即保证了可见性。

既然 volatile 能够保证线程间的变量可见性,是不是就意味着基于 volatile 变量的运算就是并发安全的?

显然不是的。基于 volatile 变量的运算在并发下不一定是安全的。volatile 变量在各个线程的工作内存,不存在一致性问题(各个线程的工作内存中 volatile 变量,每次使用前都要刷新到主内存)。但是 Java 里面的运算并非原子操作,导致 volatile 变量的运算在并发下一样是不安全的。

请对比下 volatile 对比 Synchronized 的异同。

请谈谈 ThreadLocal 是怎么解决并发安全的?

ThreadLocal 这是 Java 提供的一种保存线程私有信息的机制,因为其在整个线程生命周期内有效,所以可以方便地在一个线程关联的不同业务模块之间传递信息,比如事务 ID、Cookie 等上下文相关信息。

ThreadLocal 为每一个线程维护变量的副本,把共享数据的可见范围限制在同一个线程之内,其实现原理是,在 ThreadLocal 类中有一个 Map,用于存储每一个线程的变量的副本。

很多人都说要慎用 ThreadLocal,谈谈你的理解,使用 ThreadLocal 需要注意些什么?

使用 ThreadLocal 要注意 remove!

ThreadLocal 的实现是基于一个所谓的 ThreadLocalMap,在 ThreadLocalMap 中,它的 key 是一个弱引用。通常弱引用都会和引用队列配合清理机制使用,但是 ThreadLocal 是个例外,它并没有这么做。这意味着,废弃项目的回收依赖于显式地触发,否则就要等待线程结束,进而回收相应 ThreadLocalMap!这就是很多 OOM 的来源,所以通常都会建议,应用一定要自己负责 remove,并且不要和线程池配合,因为 worker 线程往往是不会退出的。

volatile的作用是什么?

volatile 是一个轻量级的 synchronized ,一般作用与变量,在多处理器开发的过程中保证了内存的可见性。相比于 synchronized 关键字, volatile 关键字的执行成本更低,效率更高 。

volatile的特性有哪些?

并发编程的三大特性为可见性、有序性和原子性。通常来讲 volatile 可以保证可见性和有序性

Java内存的可见性问题

Java的内存模型如下图所示。

1631531465862
1631531465862

这里的本地内存并不是真实存在的,只是Java内存模型的一个抽象概念,它包含了控制器、运算器、缓 存等。同时Java内存模型规定,线程对共享变量的操作必须在自己的本地内存中进行,不能直接在主内 存中操作共享变量。这种内存模型会出现什么问题呢?

  1. 线程A获取到共享变量X的值,此时本地内存A中没有X的值,所以加载主内存中的X值并缓存到本地内存A中,线程A修改X的值为1,并将X的值刷新到主内存中,这时主内存及本地内存A中的X的值都为1。
  2. 线程B需要获取共享变量X的值,此时本地内存B中没有X的值,加载主内存中的X值并缓存到本地内存B中,此时X的值为1。线程B修改X的值为2,并刷新到主内存中,此时主内存及本地内存B中的X值为2,本地内存A中的X值为1。
  3. 线程A再次获取共享变量X的值,此时本地内存中存在X的值,所以直接从本地内存中A获取到了X为1的值,但此时主内存中X的值为2,到此出现了所谓内存不可见的问题。该问题Java内存模型是通过 synchronized 关键字和 volatile 关键字就可以解决。

为什么代码会重排序?

计算机在执行程序的过程中,编译器和处理器通常会对指令进行重排序,这样做的目的是为了提高性能。具体可以看下面这个例子。

int a = 1;
int b = 2;
int a1 = a;
int b1 = b;
int a2 = a + a;
int b2 = b + b;

像这段代码,不断地交替读取a和b,会导致寄存器频繁交替存储a和b,使得代码性能下降,可对其进入如下重排序:

int a = 1;
int b = 2;
int a1 = a;
int a2 = a + a;
int b1 = b;
int b2 = b + b;

按照这样地顺序执行代码便可以避免交替读取a和b,这就是重排序地意义。

指令重排序一般分为编译器优化重排、指令并行重拍和内存系统重排三种。

注:简单解释下数据依赖性:如果两个操作访问了同一个变量,并且这两个操作有一个是写操作,这两个操作之间就会存在数据依赖性,例如:

a = 1;
b = a;

如果对这两个操作的执行顺序进行重排序的话,那么结果就会出现问题。

其实,这三种指令重排说明了一个问题,就是指令重排在单线程下可以提高代码的性能,但在多线程下可以会出现一些问题 。

重排序会引发什么问题?

前面已经说过了,在单线程程序中,重排序并不会影响程序的运行结果,而在多线程场景下就不一定了。可以看下面这个经典的例子,该示例出自《Java并发编程的艺术》

class ReorderExample{
    int a = 0;
    boolean flag = false;
    public void writer(){
        a = 1; // 操作1
        flag = true; // 操作2
    }
    public void reader(){
        if(flag){ // 操作3
            int i = a + a; // 操作4
        }
    }
}

假设线程1先执行 writer() 方法,随后线程2执行 reader() 方法,最后程序一定会得到正确的结果吗?

答案是不一定的,如果代码按照下图的执行顺序执行代码则会出现问题。

1631531886203
1631531886203

操作1和操作2进行了重排序,线程1先执行 flag=true ,然后线程2执行操作3和操作4,线程2执行操作4时不能正确读取到 a 的

值,导致最终程序运行结果出问题。这也说明了在多线程代码中,重排序会破坏多线程程序的语义

as-if-serial规则和happens-before规则的区别

区别:

相同点:happens-before和as-if-serial的作用都是在不改变程序执行结果的前提下,提高程序执行的并行度。

voliatile的实现原理?

前面已经讲述 volatile 具备可见性和有序性两大特性,所以 volatile 的实现原理也是围绕如何实现可见性和有序性展开的

volatile实现内存可见性原理

导致内存不可见的主要原因就是Java内存模型中的本地内存和主内存之间的值不一致所导致,例如上面所说线程A访问自己本地内存A的X值时,但此时主内存的X值已经被线程B所修改,所以线程A所访问到的值是一个脏数据。那如何解决这种问题呢?

volatile 可以保证内存可见性的关键是 volatile 的读/写实现了缓存一致性,缓存一致性的主要内容为:

那缓存一致性是如何实现的呢?可以发现通过 volatile 修饰的变量,生成汇编指令时会比普通的变量多出一个 Lock 指令,这个 Lock 指令就是 volatile 关键字可以保证内存可见性的关键,它主要有两个作用:

volatile实现有序性原理

前面提到重排序可以提高代码的执行效率,但在多线程程序中可以导致程序的运行结果不正确,那 volatile 是如何解决这一问题的呢?

Java虚拟机插入内存屏障的策略

Java内存模型把内存屏障分为4类,如下表所示:

注:StoreLoad Barriers同时具备其他三个屏障的作用,它会使得该屏障之前的所有内存访问指令完成之后,才会执行该屏障之后的内存访问命令。

Java内存模型对编译器指定的 volatile 重排序规则为:

根据 volatile 重排序规则,Java内存模型采取的是保守的屏障插入策略, volatile 写是在前面和后面分别插入内存屏障, volatile 读是在后面插入两个内存屏障,具体如下:

1631532488831
1631532488831

LoadLoad屏障的作用:禁止上面的所有普通读操作和上面的 volatile 读操作进行重排序。

LoadStore屏障的作用:禁止下面的普通写和上面的 volatile 读进行重排序。

1631532583507
1631532583507

StoreStore屏障的作用:禁止上面的普通写和下面的 volatile 写重排序

StoreLoad屏障的作用:防止上面的 volatile 写与下面可能出现的 volatile 读/写重排序。

编译器对内存屏障插入策略的优化

因为Java内存模型所采用的屏障插入策略比较保守,所以在实际的执行过程中,只要不改变volatile 读/写的内存语义,编译器通常会省略一些不必要的内存屏障。

代码演示

public class volatileBarrierDemo{
int a;
volatile int b = 1;
volatile int c = 2;
public void test(){
  int i = b; //`volatile`读
  int j = c; //`volatile`读
  a = i + j; //普通写
  }
}

指令序列示意图如下:

1631532747889
1631532747889

从上图可以看出,通过指令优化一共省略了两个内存屏障(虚线表示),省略第一个内存屏障LoadStore的原因是最后的普通写

不可能越过第二个 volatile 读,省略第二个内存屏障LoadLoad的原因是下面没有涉及到普通读的操作。

volatile能使一个非原子操作变成一个原子操作吗?

volatile 只能保证可见性和有序性,但可以保证64位的 long 型和 double 型变量的原子性。

对于32位的虚拟机来说,每次原子读写都是32位的,会将 long 和 double 型变量拆分成两个32位的操作来执行,这样 long 和 double 型变量的读写就不能保证原子性了,而通过 volatile 修饰的long和double型变量则可以保证其原子性。

volatile、synchronized的区别?

ConcurrentHashMap

什么是ConcurrentHashMap?相比于HashMap和HashTable有什么优势

CocurrentHashMap 可以看作线程安全且高效的 HashMap ,相比于 HashMap 具有线程安全的优势,相比于 HashTable 具有效率高的优势。

java中ConcurrentHashMap是如何实现的?

这里经常会将jdk1.7中的 ConcurrentHashMap 和jdk1.8中的 ConcurrentHashMap 的实现方式进行对比。

JDK1.7

在JDK1.7版本中, ConcurrentHashMap 的数据结构是由一个 Segment 数组和多个 HashEntry 数组组成, Segment 存储的是链表数组的形式,如图所示。

1631533019511
1631533019511

从上图可以看出, ConcurrentHashMap 定位一个元素的过程需要两次Hash的过程,第一次Hash的目的是定位到Segment,第二次Hash的目的是定位到链表的头部。第二次Hash所使用的时间比一次Hash的时间要长,但这样做可以在写操作时,只对元素所在的segment枷锁,不会影响到其他segment,这样可以大大提高并发能力。

JDK1.8

JDK1.8不在采用segment的结构,而是使用Node数组+链表/红黑树的数据结构来实现的(和 HashMap一样,链表节点个数大于8,链表会转换为红黑树) 如下图所示 :

1631533115608
1631533115608

从上图可以看出,对于 ConcurrentHashMap 的实现,JDK1.8的实现方式可以降低锁的粒度,因为JDLK1.7所实现的 ConcurrentHashMap 的锁的粒度是基于Segment,而一个Segment包含多个HashEntry

ConcurrentHashMap结构中变量使用volatile和final修饰有什么作用?

final 修饰变量可以保证变量不需要同步就可以被访问和共享, volatile 可以保证内存的可见性,配合CAS操作可以在不加锁的前提支持并发。

ConcurrentHashMap有什么缺点?

因为 ConcurrentHashMap 在更新数据时只会锁住部分数据,并不会将整个表锁住,读取的时候也并不能保证读取到最近的更新,只能保证读取到已经顺利插入的数据

ConcurrentHashMap默认初始容量是多少?每次扩容为原来的几倍?

默认的初始容量为16,每次扩容为之前的两倍。

ConCurrentHashMap 的key,value是否可以为null?为什么?HashMap中的key、value是否可以为null?

ConCurrentHashMap 中的 key 和 value 为 null 会出现空指针异常,而 HashMap 中的 key 和 value 值是可以为 null 的。

原因如下: ConCurrentHashMap 是在多线程场景下使用的,如果 ConcurrentHashMap.get(key) 的值为 null ,那么无法判断到底是 key 对应的 value 的值为 null 还是不存在对应的 key 值。而在单线程场景下的 HashMap 中,可以使用 containsKey(key) 来判断到底是不存在这个 key 还是 key 对应的value 的值为 null 。在多线程的情况下使用 containsKey(key) 来做这个判断是存在问题的,因为在containsKey(key) 和 ConcurrentHashMap.get(key) 两次调用的过程中, key 的值已经发生了改变。

ConCurrentHashmap在JDK1.8中,什么情况下链表会转化为红黑树?

当链表长度大于8,Node数组数大于64时 。

ConcurrentHashMap在JDK1.7和JDK1.8版本中的区别?

实现结构上的不同,JDK1.7是基于Segment实现的,JDK1.8是基于Node数组+链表/红黑树实现的。

保证线程安全方面:JDK1.7采用了分段锁的机制,当一个线程占用锁时,会锁住一个Segment对象,不会影响其他Segment对

象。JDK1.8则是采用了CAS和 synchronize 的方式来保证线程安全。

在存取数据方面:

JDK1.7中的 put() 方法:

  1. 先计算出 key 的 hash 值,利用 hash 值对segment数组取余找到对应的segment对象。
  2. 尝试获取锁,失败则自旋直至成功,获取到锁,通过计算的 hash 值对hashentry数组进行取余,找到对应的entry对象。
  3. 遍历链表,查找对应的 key 值,如果找到则将旧的value直接覆盖,如果没有找到,则添加到链表中。(JDK1.7是插入到链表头部,JDK1.8是插入到链表尾部,这里可以思考一下为什么这样)

JDK1.8中的 put() 方法:

  1. 计算 key 值的 hash 值,找到对应的 Node ,如果当前位置为空则可以直接写入数据。
  2. 利用CAS尝试写入,如果失败则自旋直至成功,如果都不满足,则利用 synchronized 锁写入数据

ConcurrentHashMap迭代器是强一致性还是弱一致性?

与HashMap不同的是, ConcurrentHashMap 迭代器是弱一致性。

这里解释一下弱一致性是什么意思,当 ConcurrentHashMap 的迭代器创建后,会遍历哈希表中的元素,在遍历的过程中,哈希表中的元素可能发生变化,如果这部分变化发生在已经遍历过的地方,迭代器则不会反映出来,如果这部分变化发生在未遍历过的地方,迭代器则会反映出来。换种说法就是put() 方法将一个元素加入到底层数据结构后, get() 可能在某段时间内还看不到这个元素。这样的设计主要是为 ConcurrenthashMap 的性能考虑,如果想做到强一致性,就要到处加锁,性能会下降很多。所以 ConcurrentHashMap 是支持在迭代过程中,向map中添加元素的,而 HashMap 这样操作则会抛出异常

ThreadLocal

什么是ThreadLocal?有哪些应用场景?

ThreadLocal 是 JDK java.lang 包下的一个类, ThreadLocal 为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量,并且不会和其他线程的局部变量冲突,实现了线程间的数据隔离。

ThreadLocal 的应用场景主要有以下几个方面:

ThreadLocal原理和内存泄露?

ThreadLocal 的原理可以概括为下图:

1631533687385
1631533687385

从上图可以看出每个线程都有一个 ThreadLocalMap , ThreadLocalMap 中保存着所有的ThreadLocal ,而 ThreadLocal 本身只是一个引用本身并不保存值,值都是保存在 ThreadLocalMap中的,其中 ThreadLocal 为 ThreadLocalMap 中的 key 。其中图中的虚线表示弱引用。

这里简单说下Java中的引用类型,Java的引用类型主要分为强引用、软引用、弱引用和虚引用。

为什么ThreadLocal会发生内存泄漏呢?

如何解决ThreadLocal的内存泄漏?

为什么要将key设计成ThreadLocal的弱引用?

线程池

什么是线程池?为什么使用线程池

线程池是一种多线程处理形式,处理过程中将任务提交到线程池,任务的执行交给线程池来管理。

为什么使用线程池?

Java 中的线程池是如何实现的?

在 Java中,所谓的线程池中的“线程”,其实是被抽象为了一个静态内部类 Worker,它基于 AQS 实现,存放在线程池的 HashSet<Worker> workers 成员变量中;

而需要执行的任务则存放在成员变量 workQueue(BlockingQueue<Runnable> workQueue)中。

这样,整个线程池实现的基本思想就是:从 workQueue 中不断取出需要执行的任务,放在 Workers 中进行处理。

创建线程池的方式

线程池的常用创建方式主要有两种,通过Executors工厂方法创建和通过new ThreadPoolExecutor方法创建。

Executors工厂方法创建,在工具类 Executors 提供了一些静态的工厂方法:

  1. newSingleThreadExecutor :创建一个单线程的线程池。
  2. newFixedThreadPool :创建固定大小的线程池。
  3. newCachedThreadPool :创建一个可缓存的线程池。
  4. newScheduledThreadPool :创建一个大小无限的线程池。

new ThreadPoolExecutor 方法创建:

new ThreadPoolExecutor(
  int corePoolSize,
  int maximumPoolSize, 
  long keepAliveTime, 
  TimeUnit unit, 
  BlockingQueue<Runnable> workQueue,
    ThreadFactory threadFactory,
  RejectedExecutionHandler handler)

ThreadPoolExecutor构造函数的重要参数分析

三个比较重要的参数:

其他参数:

线程池中的线程是怎么创建的?是一开始就随着线程池的启动创建好的吗?

显然不是的。线程池默认初始化后不启动 Worker,等待有请求时才启动。

每当我们调用 execute() 方法添加一个任务时,线程池会做如下判断:

ThreadPoolExecutor的饱和策略(拒绝策略 )

当同时运行的线程数量达到最大线程数量并且阻塞队列也已经放满了任务时, ThreadPoolExecutor 会指定一些饱和策略。主要有以下四种类型:

线程池的执行流程

创建线程池创建后提交任务的流程如下图所示:

1631534178687
1631534178687

如何在 Java 线程池中提交线程?

线程池最常用的提交任务的方法有两种:

  1. execute():ExecutorService.execute 方法接收一个 Runable 实例,它用来执行一个任务:
1632035369538
1632035369538
  1. submit():ExecutorService.submit() 方法返回的是 Future 对象。可以用 isDone() 来查询 Future 是否已经完成,当任务完成时,它具有一个结果,可以调用 get() 来获取结果。也可以不用 isDone() 进行检查就直接调用 get(),在这种情况下,get() 将阻塞,直至结果准备就绪。
1632035410474
1632035410474

如果你提交任务时,线程池队列已满,这时会发生什么?

  1. 如果你使用的LinkedBlockingQueue,也就是无界队列列的话,没关系,继续添加任务到阻塞队列中等待执行,因为 LinkedBlockingQueue可以近乎认为是一个无穷大的队列,可以无限存放任务;
  2. 如果你使用的是有界队列比方说ArrayBlockingQueue的话,任务首先会被添加到ArrayBlockingQueue中,ArrayBlockingQueue满了了,则会使用拒绝策略RejectedExecutionHandler处理理满了的任务,默认是AbortPolicy。

execute()方法和submit()方法的区别

这个地方首先要知道Runnable接口和Callable接口的区别,之前有写到过

execute() 和 submit() 的区别主要有两点:

既然提到可以通过配置不同参数创建出不同的线程池,那么 Java 中默认实现好的线程池又有哪些呢?请比较它们的异同。

SingleThreadExecutor 线程池

这个线程池只有一个核心线程在工作,也就是相当于单线程串行执行所有任务。如果这个唯一的线程因为异常结束,那么会有一个新的线程来替代它。此线程池保证所有任务的执行顺序按照任务的提交顺序执行。

- `corePoolSize:1`,只有一个核心线程在工作。
- `maximumPoolSize`:1。
- `keepAliveTime`:0L。
- `workQueue:new LinkedBlockingQueue<Runnable>()`,其缓冲队列是无界的。

FixedThreadPool 线程池

FixedThreadPool 是固定大小的线程池,只有核心线程。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。

FixedThreadPool 多数针对一些很稳定很固定的正规并发线程,多用于服务器。

- corePoolSize:nThreads
- maximumPoolSize:nThreads
- keepAliveTime:0L
- workQueue:new LinkedBlockingQueue<Runnable>(),其缓冲队列是无界的。

CachedThreadPool 线程池

CachedThreadPool 是无界线程池,如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(60 秒不执行任务)线程,当任务数增加时,此线程池又可以智能的添加新线程来处理任务。

线程池大小完全依赖于操作系统(或者说 JVM)能够创建的最大线程大小。SynchronousQueue 是一个是缓冲区为 1 的阻塞队列。 缓存型池子通常用于执行一些生存期很短的异步型任务,因此在一些面向连接的 daemon 型 SERVER 中用得不多。但对于生存期短的异步任务,它是 Executor 的首选。

- corePoolSize:0
- maximumPoolSize:Integer.MAX_VALUE
- keepAliveTime:60L
- workQueue:new SynchronousQueue<Runnable>(),一个是缓冲区为 1 的阻塞队列。

ScheduledThreadPool 线程池

ScheduledThreadPool:核心线程池固定,大小无限的线程池。此线程池支持定时以及周期性执行任务的需求。创建一个周期性执行任务的线程池。如果闲置,非核心线程池会在 DEFAULT_KEEPALIVEMILLIS 时间内回收。