面试准备系列——Java基础技术篇(10)/Java并发编程(高频面试题)


文章主要问题和解答来源于 《Java开发岗高频面试提全解析》

线程这个知识点是面试中常温的知识点之一,常问的问题包括:

  • 单线程和多线程,进程与线程的区别
  • 线程活性故障及其解决方法
  • 线程调度方式
  • 可见性,原子性以及有序性
  • synchronized,volatile,Atomic等关键字
  • 线程池及阻塞队列

1.(重点)进程与线程的区别(协程)

进程与线程之间的主要区别可以总结如下。

  • 进程是一个“执行中的程序”,是系统进行资源分配和调度的一个独立(基本)单位
  • 线程是进程的一个实体,是独立调度的基本单位,一个进程中一般拥有多个线程。线程之间共享地址空间和其它资源(所以通信和同步等操作,线程比进程更加容易)
  • 线程一般不拥有系统资源,但是也有一些必不可少的资源(使用ThreadLocal存储)
  • 线程上下文的切换比进程上下文切换要快很多。

线程上下文切换比进程上下文切换快的原因,可以总结如下:

  • 进程切换时,涉及到当前进程的CPU环境的保存和新被调度运行进程的CPU环境的设置
  • 线程切换时,仅需要保存和设置少量的寄存器内容,不涉及存储管理方面的操作

另外还有一个协程的问题,首先需要明确的是,协程是一种特殊的函数,一个线程内可能存在着多个的协程。一个线程内的多个协程虽然可以切换,但是多个协程是串行执行的,只能在一个线程内运行,没法利用CPU多核能力。

解析:

进程与线程的区别算是一个开场题目,旨在考察大家对进程与线程的理解,因为我们的多线程是指在一个进程中的多个线程。

前面我们说线程之间共享一个进程的资源和地址空间,那么线程可以拥有独属于自己的资源吗

答:可以的,通过ThreadLocal可以存储线程的特有对象,也就是属于当前线程的资源。

既然说到了进程,那么来看下进程之间有哪些通信方式吧。

进程之间常见的通信方式:

  • 通过使用套接字Socket来实现不同机器间的进程通信
  • 通过映射一段可以被多个进程访问的共享内存来进行通信
  • 通过写进程和读进程利用管道进行通信

常见的五种通讯方式:管道,消息队列,共享内存,信号量,Socket
详细的解释可以参考:https://mp.weixin.qq.com/s/5CbYGrylSKx1JwtOiW3aOQ

2.(重点)多线程与单线程的关系

多线程与单线程之间的关系可以概括如下。(通俗意义上讲就是一边读书一遍听音乐的问题)

  • 多线程是指在一个进程中,并发执行了多个线程,每个线程都实现了不同的功能
  • 在单核CPU中,将CPU分为很小的时间片,在每一时刻只能有一个线程在执行,是一种微观上轮流占用CPU的机制。由于CPU轮询的速度非常快,所以看起来像是“同时”在执行一样
  • 多线程会存在线程上下文切换,会导致程序执行速度变慢
  • 多线程不会提高程序的执行速度,反而会降低速度。但是对于用户来说,可以减少用户的等待响应时间,提高了资源的利用效率

解析:

搞清楚多线程和单线程之间的区别,有助于我们理解为什么要使用多线程并发编程。多线程并发利用了CPU轮询时间片的特点,在一个线程进入阻塞状态时,可以快速切换到其余线程执行其余操作,这有利于提高资源的利用率,最大限度的利用系统提供的处理能力,有效减少了用户的等待响应时间。

但是,多线程并发编程也会带来数据的安全问题,线程之间的竞争也会导致线程死锁和锁死等活性故障。线程之间的上下文切换也会带来额外的开销等问题。

(重点)3.线程的状态有哪些?

线程的状态包括 新建状态,运行状态,阻塞等待状态和消亡状态。其中阻塞等待状态又分为BLOCKED, WAITING和TIMED_WAITING状态。
在这里插入图片描述

  • (1)NEW(新建状态):
    看的出来,这是属于一个已经创建的线程,但是还没有调用start方法启动的线程所处的状态。
  • (2)RUNNABLE(运行时状态):
    正如JDK中介绍,该状态包含两种可能。有可能正在运行,或者正在等待CPU资源。总体上就是当我们创建线程并且启动之后,就属于Runnable状态。
  • (3)BLOCKED(阻塞状态):
    阻塞状态,当线程准备进入synchronized同步块或同步方法的时候,需要申请一个监视器锁而进行的等待,会使线程进入BLOCKED状态。
  • WAITING(等待状态):
    该状态的出现是因为调用了Object.wait()或者Thread.join()或者LockSupport.park()。处于该状态下的线程在等待另一个线程 执行一些其余action来将其唤醒。
  • TIMED_WAITING(计时等待状态)
    该状态和上一个状态其实是一样的,是不过其等待的时间是明确的。
  • TERMINATED(终止状态/消亡状态):
    消亡状态比较容易理解,那就是线程执行结束了,run方法执行结束表示线程处于消亡状态了。

4.多线程编程常用函数以及比较:

多线程编程中的常用函数的比较和特性总结如下。

sleep 和 wait 的区别:

sleep方法:是Thread类的静态方法,当前线程将睡眠n毫秒,线程进入阻塞状态。当睡眠时间到了,会解除阻塞,进入可运行状态,等待CPU的到来。睡眠不释放锁(如果有的话)。
wait方法:是Object的方法必须与synchronized关键字一起使用,线程进入阻塞状态,当notify或者notifyall被调用后,会解除阻塞。但是,只有重新占用互斥锁之后才会进入可运行状态。睡眠时,会释放互斥锁。

join 方法:当前线程调用,则其它线程全部停止,等待当前线程执行完毕,接着执行。
或者这样解释join:

当前线程调用其他线程的join方法,会阻塞当前线程,直到其他线程执行完毕,才会进入就绪状态。

join方法是被Synchronized关键字所修饰,访问时,需要获得其他线程对象的锁,如果有两个线程同时调用另外一个线程的join方法,会有一个线程成功得到锁,而另外一个则必须等待,进入阻塞状态,而在得到锁之后,才会执行join方法。

join()方法是通过wait()方法 (Object 提供的方法) 实现的。当 millis == 0 时,会进入 while(isAlive()) 循环,并且只要子线程是活的, 宿主线程就不停的等待。 join方法同样会会让线程交出CPU执行权限; join方法同样会让线程释放对一个对象持有的锁;

yield 方法:该方法使得线程放弃当前分得的 CPU 时间。但是不使线程阻塞,即线程仍处于可执行状态,随时可能再次分得 CPU 时间。

它同样不会释放锁。

解析:
这个题目主要是考察 sleep和wait方法所处的类是哪个,并且考察其在休眠的时候对于互斥锁的处理。

5.线程活性故障有哪些

由于资源的稀缺性或者程序自身的问题导致线程一直处于非Runnable状态,并且其处理的任务一直无法完成的现象被称为是线程活性故障。常见的线程活性故障包括死锁,锁死,活锁与线程饥饿。

解析:

每一个线程都有其特定的任务处理逻辑。由于资源的稀缺性或者资源本身的一些特性,导致多个线程需要共享一些排他性资源,比如说处理器,数据库连接等。当出现资源争用的时候,部分线程会进入等待状态。接下来,让我们依次介绍各种形式的线程活性故障吧。

(1)线程死锁:(重点掌握)
死锁是最常见的一种线程活性故障。死锁的起因是多个线程之间相互等待对方而被永远暂停(处于非Runnable)。死锁的产生必须满足如下四个必要条件:

  • 资源互斥:一个资源每次只能被一个线程使用
  • 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放
  • 不剥夺条件:线程已经获得的资源,在未使用完之前,不能强行剥夺
  • 循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系

解决线程死锁的方法:

  • 粗锁法:使用一个粒度粗的锁来消除“请求与保持条件”,缺点是会明显降低程序的并发性能并且会导致资源的浪费。

  • 锁排序法:(必须回答出来的点)
    指定获取锁的顺序,比如某个线程只有获得A锁和B锁,才能对某资源进行操作,在多线程条件下,如何避免死锁?

通过指定锁的获取顺序,比如规定,只有获得A锁的线程才有资格获取B锁,按顺序获取锁就可以避免死锁。这通常被认为是解决死锁很好的一种方法。

  • 使用显式锁中的ReentrantLock.try(long,TimeUnit)来申请锁

死锁总结:
关于线程活性故障中最常见的死锁,我们必须熟悉其产生的4个必要条件,根据必要条件还应该掌握其避免死锁的方法,锁排序法请大家务必熟练掌握。

(2)线程锁死:
线程锁死是另一种常见的线程活性故障,与线程死锁不可以混为一谈。线程锁死的定义如下:

线程锁死是指等待线程由于唤醒其所需的条件永远无法成立,或者其他线程无法唤醒这个线程而一直处于非运行状态(线程并未终止)导致其任务 一直无法进展。

线程死锁和线程锁死的外部表现是一致的,即故障线程一直处于非运行状态使得其所执行的任务没有进展。但是锁死的产生条件和线程死锁不一样,即使产生死锁的4个必要条件都没有发生,线程锁死仍然可能已经发生。

线程锁死分为了如下两种:

  • 信号丢失锁死:
    信号丢失锁死是因为没有对应的通知线程来将等待线程唤醒,导致等待线程一直处于等待状态。

典型例子是等待线程在执行Object.wait( )/Condition.await( )前没有对保护条件进行判断,而此时保护条件实际上可能已经成立,此后可能并无其他线程更新相应保护条件涉及的共享变量使其成立并通知等待线程,这就使得等待线程一直处于等待状态,从而使其任务一直无法进展。

  • 嵌套监视器锁死:
    嵌套监视器锁死是由于嵌套锁导致等待线程永远无法被唤醒的一种故障。

比如一个线程,只释放了内层锁Y.wait(),但是没有释放外层锁X; 但是通知线程必须先获得外层锁X,才可以通过 Y.notifyAll()来唤醒等待线程,这就导致出现了嵌套等待现象。

活锁:
活锁是一种特殊的线程活性故障。当一个线程一直处于运行状态,但是其所执行的任务却没有任何进展称为活锁。比如,一个线程一直在申请其所需要的资源,但是却无法申请成功。

线程饥饿:
线程饥饿是指线程一直无法获得其所需的资源导致任务一直无法运行的情况。线程调度模式有公平调度和非公平调度两种模式。在线程的非公平调度模式下,就可能出现线程饥饿的情况。

线程活性故障总结:

  • 线程饥饿发生时,如果线程处于可运行状态,也就是其一直在申请资源,那么就会转变为活锁
  • 只要存在一个或多个线程因为获取不到其所需的资源而无法进展就是线程饥饿,所以线程死锁其实也算是线程饥饿

线程安全问题

对线程安全相关问题,我们先来看一个线程安全的经典问题:多个窗口售票问题

public class Demo {
 
    public static void main(String[] args) {
 
        TicketSale ticketSale = new TicketSale();
        Thread Sale1 = new Thread(ticketSale, "售票口1");
        Thread Sale2 = new Thread(ticketSale, "售票口2");
        Thread Sale3 = new Thread(ticketSale, "售票口3");
        Thread Sale4 = new Thread(ticketSale, "售票口4");
        // 启动线程,开始售票
        Sale1.start();
        Sale2.start();
        Sale3.start();
        Sale4.start();
    }
}
 
class TicketSale implements Runnable {
    int ticketSum = 100;
 
    @Override
    public void run() {
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 有余票,就卖
        while (ticketSum > 0) {
            System.out.println(Thread.currentThread().getName() + "售出第" + (100 - ticketSum + 1) + "张票");
            ticketSum--;
        }
        System.out.println(Thread.currentThread().getName() + "表示没有票了");
    }
}

部分输出:
在这里插入图片描述
由图中可以看出,在多线程并发情况下,出现了同一张票被多个窗口卖出的情况,也就是出现了线程安全的问题。多线程环境下的线程安全主要体现在原子性,可见性与有序性方面。接下来,我们依次介绍三大特性。

6.原子性,可见性与有序性

多线程环境下的线程安全主要体现在原子性,可见性与有序性方面。

原子性

定义:对于涉及到共享变量访问的操作,若该操作从执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作,该操作具有原子性。即,其它线程不会“看到”该操作执行了部分的中间结果

举例:银行转账流程中,A账户减少了100元,那么B账户就会多100元,这两个动作是一个原子操作。我们不会看到A减少了100元,但是B余额保持不变的中间结果

原子性的实现方式:

  • 利用锁的排他性,保证同一时刻只有一个线程在操作一个共享变量
  • 利用 CAS(Compare And Swap) 保证
  • Java语言规范中,保证了除long和double型以外的任何变量的写操作都是原子操作
  • Java语言规范中又规定,volatile关键字修饰的变量可以保证其写操作的原子性

关于原子性,你应该注意的地方:

  • 原子性针对的是多个线程的共享变量,所以对于局部变量来说不存在共享问题,也就无所谓是否是原子操作
  • 单线程环境下讨论是否是原子操作没有意义
  • volatile关键字仅仅能保证变量写操作的原子性,不保证复合操作,比如说读写操作的原子性

可见性

定义:可见性是指一个线程对于共享变量的更新,对于后续访问该变量的线程是否可见的问题。

为了阐述可见性问题,我们先来简单介绍处理器缓存的概念。

现代处理器处理速度远大于主内存的处理速度,所以在主内存和处理器之间加入了寄存器,高速缓存,写缓冲器以及无效化队列等部件来加速内存的读写操作。也就是说,我们的处理器可以和这些部件进行读写操作的交互,这些部件可以称为处理器缓存。

处理器对内存的读写操作,其实仅仅是与处理器缓存进行了交互。一个处理器的缓存上的内容无法被另外一个处理器读取,所以另外一个处理器必须通过缓存一致性协议来读取的其他处理器缓存中的数据,并且同步到自己的处理器缓存中,这样保证了其余处理器对该变量的更新对于另外处理器是可见的。

在单处理器中,为什么也会出现可见性的问题呢?
单处理器中,由于是多线程并发编程,所以会存在线程的上下文切换,线程会将对变量的更新当作上下文存储起来,导致其余线程无法看到该变量的更新。所以单处理器下的多线程并发编程也会出现可见性问题的。

可见性如何保证?

  • 当前处理器需要刷新处理器缓存,使得其余处理器对变量所做的更新可以同步到当前的处理器缓存中
  • 当前处理器对共享变量更新之后,需要冲刷处理器缓存,使得该更新可以被写入处理器缓存中

有序性

定义:有序性是指一个处理器上运行的线程所执行的内存访问操作在另外一个处理器上运行的线程来看是否有序的问题。

重排序
为了提高程序执行的性能,Java编译器在其认为不影响程序正确性的前提下,可能会对源代码顺序进行一定的调整,导致程序运行顺序与源代码顺序不一致。

重排序是对内存读写操作的一种优化,在单线程环境下不会导致程序的正确性问题,但是多线程环境下可能会影响程序的正确性。

重排序举例:
Instance instance = new Instance()都发生了啥?
具体步骤如下所示三步:

  • 在堆内存上分配对象的内存空间
  • 在堆内存上初始化对象
  • 设置instance指向刚分配的内存地址

第二步和第三步可能会发生重排序,导致引用型变量指向了一个不为null但是也不完整的对象。(在多线程下的单例模式中,我们必须通过volatile来禁止指令重排序)

指令重排可以直接通过一个案例来看看:
指令重排的经典案例:

int a = 1;
int b = 2;
int c = a + b;

将上面的代码编译成Java字节码或生成机器指令,可视为展开成了以下几步动作(实际可能会省略或添加某些步骤)。
1.对a赋值1
2.对b赋值2
3.取a的值
4.取b的值
5.将取到两个值相加后存入c
在上面5个动作中,动作1可能会和动作2、4重排序,动作2可能会和动作1、3重排序,动作3可能会和动作2、4重排序, 动作4可能会和1、3重排序。但动作1和动作3、5不能重排序。动作2和动作4、5不能重排序。因为它们之间存在数据依赖关系, 一旦重排,as-if-serial语义便无法保证。

JIT的优化的原则是,尽力优化正常运行下的代码逻辑,哪怕以catch块逻辑变得复杂为代价,毕竟,进入catch块内是一种“异常”情况的表现。

导致指令重排的原因通常有:
编译器优化的重排序,指令级并行的重排序,内存系统的重排序。。。。

解析:

  • 原子性是一组操作要么完全发生,要么没有发生,其余线程不会看到中间过程的存在。注意,原子操作+原子操作不一定还是原子操作。
  • 可见性是指一个线程对共享变量的更新对于另外一个线程是否可见的问题。
  • 有序性是指一个线程对共享变量的更新在其余线程看起来是按照什么顺序执行的问题。
  • 可以这么认为,原子性 + 可见性 -> 有序性

7.谈谈你对synchronized关键字的理解

synchronized是Java中的一个关键字,是一个内部锁。它可以使用在方法上和方法块上,表示同步方法和同步代码块。在多线程环境下,同步方法或者同步代码块在同一时刻只允许有一个线程在执行,其余线程都在等待获取锁,也就是实现了整体并发中的局部串行。

内部锁底层实现:

  • 进入时,执行monitorenter,将计数器+1,释放锁monitorexit时,计数器-1
  • 当一个线程判断到计数器为0时,则当前锁空闲,可以占用;反之,当前线程进入等待状态

synchronized内部锁对原子性的保证:

锁通过互斥来保障原子性,互斥是指一个锁一次只能被一个线程所持有,所以,临界区代码只能被一个线程执行,即保障了原子性。

synchronized内部锁对可见性的保证:

synchronized内部锁通过写线程冲刷处理器缓存和读线程刷新处理器缓存保证可见性。

  • 获得锁之后,需要刷新处理器缓存,使得前面写线程所做的更新可以同步到本线程。
  • 释放锁需要冲刷处理器缓存,使得当前线程对共享数据的改变可以被推送到下一个线程处理器的高速缓冲中。

synchronized内部锁对有序性的保证:

由于原子性和可见性的保证,使得写线程在临界区中所执行的一系列操作在读线程所执行的临界区看起来像是完全按照源代码顺序执行的,即保证了有序性。

解析:

synchronized是Java中的关键字,其实就是一个内部锁,关于内部锁的考察也是Java面试中的高频考点。内部锁可以使用在方法上和代码块上,被内部锁修饰的区域又叫做临界区。如下所示:

public class SynchronizedTest {
 
    public static void main(String[] args) {
 
        synchronized (SynchronizedTest.class){
            System.out.println("这是一个同步方法块");
        }
 
    }
 
    public synchronized void test(){
        System.out.println("这是一个同步方法,因为在方法上使用了synchronized关键字");
    }
}

接下来,我们继续介绍其相关知识点。锁做为一种资源,JVM对资源的调度分为公平调度和非公平调度方式。
公平调度方式:
按照申请的先后顺序授予资源的独占权。

非公平调度方式:
在该策略中,资源的持有线程释放该资源的时候,等待队列中一个线程会被唤醒,而该线程从被唤醒到其继续执行可能需要一段时间。在该段时间内,**新来的线程(活跃线程)**可以先被授予该资源的独占权。

如果新来的线程占用该资源的时间不长,那么它完全有可能在被唤醒的线程继续执行前释放相应的资源,从而不影响该被唤醒的线程申请资源。

优缺点分析:
非公平调度策略:

  • 优点:吞吐率较高,单位时间内可以为更多的申请者调配资源
  • 缺点:资源申请者申请资源所需的时间偏差可能较大,并可能出现线程饥饿的现象

公平调度策略:

  • 优点:线程申请资源所需的时间偏差较小;不会出现线程饥饿的现象;适合在资源的持有线程占用资源的时间相对长或者资源的平均申请时间间隔相对长的情况下,或者对资源申请所需的时间偏差有所要求的情况下使用;
  • 缺点:吞吐率较小

接下来,我们一起来看看JVM对synchronized内部锁的调度方式吧。

JVM对synchronized内部锁的调度:
JVM对内部锁的调度是一种非公平的调度方式,JVM会给每个内部锁分配一个入口集(Entry Set),用于记录等待获得相应内部锁的线程。当锁被持有的线程释放的时候,该锁的入口集中的任意一个线程将会被唤醒,从而得到再次申请锁的机会。被唤醒的线程等待占用处理器运行时可能还有其他新的活跃线程与该线程抢占这个被释放的锁.

8.谈谈你对volatile关键字的理解

volatile关键字是一个轻量级的锁,可以保证可见性和有序性,但是不保证原子性。

解析:

  • volatile 可以保证主内存和工作内存直接产生交互,进行读写操作,保证可见性
  • volatile 仅能保证变量写操作的原子性,不能保证读写操作的原子性。
  • volatile可以禁止指令重排序(通过插入内存屏障),典型案例是在单例模式中使用。

volatile变量的开销:

volatile不会导致线程上下文切换,但是其读取变量的成本较高,因为其每次都需要从高速缓存或者主内存中读取,无法直接从寄存器中读取变量。

volatile在什么情况下可以替代锁?

volatile是一个轻量级的锁,适合多个线程共享一个状态变量,锁适合多个线程共享一组状态变量。可以将多个线程共享的一组状态变量合并成一个对象,用一个volatile变量来引用该对象,从而替代锁。

volatile禁止指令重排的实现逻辑小案例:
在这里插入图片描述
在这里插入图片描述
由于加入了StoreStore屏障,屏障上方的普通写入语句 context = loadContext() 和屏障下方的volatile写入语句 contextReady = true 无法交换顺序,从而成功阻止了指令重排序。

Java内存屏障主要有Load和Store两类
Load Barrier来说,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据
Store Barrier来说,在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存

对于Load和Store,在实际使用中,又分为四种:
LoadLoad 屏障 、StoreStore 屏障 、LoadStore 屏障 、StoreLoad 屏障

9.ReentrantLock和synchronized的区别

ReentrantLock和synchronized 都是可重入锁。

可重入锁的概念
可重入就是说某个线程已经获得某个锁,可以再次获取锁而不会出现死锁。

ReentrantLock显式锁,其提供了一些内部锁不具备的特性,但并不是内部锁的替代品。显式锁支持公平和非公平的调度方式,默认采用非公平调度。

synchronized 内部锁简单,但是不灵活。显式锁支持在一个方法内申请锁,并且在另一个方法里释放锁。显式锁定义了一个tryLock()方法,尝试去获取锁,成功返回true,失败并不会导致其执行的线程被暂停而是直接返回false,即可以避免死锁。

10.Java中的线程池有了解吗?

java中创建线程池的方式一般有两种:

  • 通过Executors工厂方法创建,例如通过Executors.newSingleThreadExecutor()Executors.newFixedThreadPool()Executors.newCachedThreadPool(),然后直接通过submit方法(返回Future类)或者execute方法(没有返回值)执行线程所需要执行的任务。
  • 通过new ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue workQueue)自定义创建
    对于阻塞队列一般这样选择:ArrayBlockingQueue和PriorityBlockingQueue使用较少,一般使用LinkedBlockingQueue和SynchronousQueue。线程池的排队策略与BlockingQueue有关。

java.util.concurrent.ThreadPoolExecutor类就是一个线程池。客户端调用ThreadPoolExecutor.submit(Runnable task)提交任务,线程池内部维护的工作者线程的数量就是该线程池的线程池大小,有3种形态:

  • 当前线程池大小:表示线程池中实际工作者线程的数量
  • 最大线程池大小(maxinumPoolSize):表示线程池中允许存在的工作者线程的数量上限
  • 核心线程大小(corePoolSize ):表示一个不大于最大线程池大小的工作者线程数量上限

线程池的优势体现如下:

  • 线程池可以重复利用已创建的线程,一次创建可以执行多次任务,有效降低线程创建和销毁所造成的资源消耗;
  • 线程池技术使得请求可以快速得到响应,节约了创建线程的时间;
  • 线程的创建需要占用系统内存,消耗系统资源,使用线程池可以更好的管理线程,做到统一分配、调优和监控线程,提高系统的稳定性。

解析:

创建线程是有开销的,为了重复利用已创建的线程降低线程创建和销毁的消耗,提高资源的利用效率,所以出现了线程池。线程池的参数字段如下所示:
在这里插入图片描述
我们来看下JDK对各个字段的解释:

  • corePoolSize:核心线程数
  • maximumPoolSize:最大线程数
  • keepAliveTime :线程空闲但是保持不被回收的时间
  • unit:时间单位
  • workQueue:存储线程的队列
  • threadFactory:创建线程的工厂
  • handler:拒绝策略

线程池的排队策略:

当我们向线程池提交任务的时候,需要遵循一定的排队策略,具体策略如下:

  • 如果运行的线程少于corePoolSize,则Executor始终首选添加新的线程,而不进行排队
  • 如果运行的线程等于或者多于corePoolSize,则Executor始终首选将请求加入队列,而不是添加新线程
  • 如果无法将请求加入队列,即队列已经满了,则创建新的线程,除非创建此线程超出maxinumPoolSize,在这种情况下,任务默认将被拒绝

常见的线程池类型:
newCachedThreadPool( )

  • 核心线程池大小为0,最大线程池大小不受限,来一个创建一个线程
  • 适合用来执行大量耗时较短且提交频率较高的任务

newFixedThreadPool( )

  • 固定大小的线程池
  • 当线程池大小达到核心线程池大小,就不会增加也不会减小工作者线程的固定大小的线程池

newSingleThreadExecutor( )

  • 便于实现单(多)生产者-消费者模式

常见的阻塞队列:
前面我们介绍了线程池内部有一个排队策略,任务可能需要在队列中进行排队等候。常见的阻塞队列包括如下的三种,接下来我们一起来看看吧。

ArrayBlockingQueue:

  • 内部使用一个数组作为其存储空间,数组的存储空间是预先分配
  • 优点是 put 和 take操作不会增加GC的负担(因为空间是预先分配的)
  • 缺点是 put 和 take操作使用同一个锁,可能导致锁争用,导致较多的上下文切换。
  • ArrayBlockingQueue适合在生产者线程和消费者线程之间的并发程序较低的情况下使用。

LinkedBlockingQueue

  • 是一个无界队列(其实队列长度是Integer.MAX_VALUE)
  • 内部存储空间是一个链表,并且链表节点所需的存储空间是动态分配的
  • 优点是 put 和 take 操作使用两个显式锁(putLock和takeLock)
  • 缺点是增加了GC的负担,因为空间是动态分配的。
  • LinkedBlockingQueue适合在生产者线程和消费者线程之间的并发程序较高的情况下使用。

SynchronousQueue
SynchronousQueue可以被看做一种特殊的有界队列。生产者线程生产一个产品之后,会等待消费者线程来取走这个产品,才会接着生产下一个产品,适合在生产者线程和消费者线程之间的处理能力相差不大的情况下使用。

我们前边介绍newCachedThreadPool时候说,这个线程池来一个线程就创建一个,这是因为其内部队列使用了SynchronousQueue,所以不存在排队。

关于线程池,你应该知道的事情:

  • 使用JDK提供的快捷方式创建线程池,比如说newCachedThreadPool会出现一些内存溢出的问题,因为队列可以被塞入很多任务。所以,大多数情况下,我们都应该自定义线程池
  • 线程池提供了一些监控API,可以很方便的监控当前以及塞进队列的任务数以及当前线程池已经完成的任务数等。

11.CountDownLatch和CyclicBarrier有了解吗

两个关键字经常放在一起比较和考察,下边我们分别介绍。

CountDownLatch是一个倒计时协调器,它可以实现一个或者多个线程等待其余线程完成一组特定的操作之后,继续运行。

CountDownLatch的内部实现如下:

  • CountDownLatch内部维护一个计数器,CountDownLatch.countDown()每被执行一次都会使计数器值减少1。
  • 当计数器不为0时,CountDownLatch.await()方法的调用将会导致执行线程被暂停,这些线程就叫做该CountDownLatch上的等待线程。
  • CountDownLatch.countDown()相当于一个通知方法,当计数器值达到0时,唤醒所有等待线程。当然对应还有指定等待时间长度的CountDownLatch.await( long , TimeUnit)方法。

CyclicBarrier是一个栅栏,可以实现多个线程相互等待执行到指定的地点,这时候这些线程会再接着执行,在实际工作中可以用来模拟高并发请求测试。

**可以认为是这样的,**当我们爬山的时候,到了一个平坦处,前面队伍会稍作休息,等待后边队伍跟上来,当最后一个爬山伙伴也达到该休息地点时,所有人同时开始从该地点出发,继续爬山。

CyclicBarrier的内部实现如下:

  • 使用CyclicBarrier实现等待的线程被称为参与方(Party),参与方只需要执行CyclicBarrier.await()就可以实现等待,该栅栏维护了一个显式锁,可以识别出最后一个参与方,当最后一个参与方调用await()方法时,前面等待的参与方都会被唤醒,并且该最后一个参与方也不会被暂停。
  • CyclicBarrier内部维护了一个计数器变量count = 参与方的个数,调用await方法可以使得count -1。当判断到是最后一个参与方时,调用singalAll唤醒所有线程。

12.ThreadLocal有了解吗

使用ThreadLocal维护变量时,其为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立的改变自己的副本,而不会影响其他线程对应的副本。

ThreadLocal内部实现机制:

  • 每个线程内部都会维护一个类似HashMap的对象,称为ThreadLocalMap,里边会包含若干了Entry(K-V键值对),相应的线程被称为这些Entry的属主线程
  • Entry的Key是一个ThreadLocal实例,Value是一个线程特有对象。Entry的作用是为其属主线程建立起一个ThreadLocal实例与一个线程特有对象之间的对应关系
  • Entry对Key的引用是弱引用;Entry对Value的引用是强引用。

13.Atmoic有了解吗

介绍Atomic之前先来看一个问题吧,i++操作是线程安全的吗

i++操作并不是线程安全的,它是一个复合操作,包含三个步骤:

  • 拷贝i的值到临时变量
  • 临时变量++操作
  • 拷贝回原始变量i

这是一个复合操作,不能保证原子性,所以这不是线程安全的操作。那么如何实现原子自增等操作呢

这里就用到了JDK在java.util.concurrent.atomic包下的AtomicInteger等原子类了。AtomicInteger类提供了getAndIncrement和incrementAndGet等原子性的自增自减等操作。Atomic等原子类内部使用了CAS来保证原子性。

接下来,我们来看代码吧,首先是使用变量i的情况:

class ThreadTest implements Runnable {
 
        static int i = 0;
        public void run() {
            for (int m = 0; m < 1000000; m++) {
                i++;
            }
        }
};
public class Test {
    public static void main(String[] args) throws InterruptedException {
        ThreadTest mt = new ThreadTest();
 
        Thread t1 = new Thread(mt);
        Thread t2 = new Thread(mt);
        t1.start();
        t2.start();
        // 休眠一下,让线程执行完毕。
        Thread.sleep(500);
        System.out.println(ThreadTest.i);
    }
}

该程序的输出是不确定的,比如输出1933446,也就是线程不安全,发生了竟态导致计算结果有误。

当我们使用了Atomic等原子类时,会发现每次输出结果都是2000000,符合我们的程序设计要求。

import java.util.concurrent.atomic.AtomicInteger;
 
class ThreadTest implements Runnable {
 
    static AtomicInteger i = new AtomicInteger(0);
 
    public void run() {
        for (int m = 0; m < 1000000; m++) {
            i.getAndIncrement();
        }
    }
};
 
public class Test {
    public static void main(String[] args) throws InterruptedException {
        ThreadTest mt = new ThreadTest();
 
        Thread t1 = new Thread(mt);
        Thread t2 = new Thread(mt);
        t1.start();
        t2.start();
        // 休眠一下,让线程执行完毕。
        Thread.sleep(500);
        System.out.println(ThreadTest.i.get());
    }
}

14.AQS(AbstractQueuedSynchronizer)的原理与实现

AQS又称为队列同步器,用来构建锁或其他同步组件的基础框架。 内部通过一个int成员变量state来控制同步状态,当state = 0时,说明没有任何线程占有共享资源的锁state = 1时,则说明有线程目前正在使用共享变量其他线程必须加入同步队列进行等待,当然state也 可以继续执行+1操作,比如可重入锁。AQS同步器的实现依赖于内部的同步队列(FIFO的双向链表队列)完成 对同步状态(state)的管理,当前线程获取锁(同步状态)失败时,AQS会将该线程以及相关等待信息包装成 一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会将头结点head中的线程唤醒,让其尝试获取同步状态。简单来说,就是同步状态state和同步队列。ReentrantLock锁就是使用了AQS 来控制同步状态。

15.如何管理线程

  • 1.从操作系统的层面上来说,线程是个实体概念,操作系统用PCB(Process Control Block,进程(线程)控制块)来表示线程的实体,里面就有线程的名称,线程优先级,线程当前状态等各种信息。那么操作系统有一个PCB表(其实是队列),上面记录这当前所有PCB,也就是记录了所有的线程,先标记为All_T_T(All Thread Table);除了这个表之外,还有一个就绪的线程的表(也是队列),标记为Ready_T_T(Ready_Thread_Table)。说一下线程的状态:Ready,Running,Waiting等。只有Ready状态的,才会放到Ready_T_T中,其他状态包括Running都放在All_T_T中。操作系统拿着这两个表,坐在最前面,大喊,线程2,你的时间片到了,你从CPU上下来,线程3,该你上CPU了。线程2在下来之前(因为只有在CPU上的时候,才能有动作发出),如果自己现在还是在可执行状态,就把自己的Running状态修改为Ready,并把自己加到Ready_T_T中,这时候线程3是没上CPU的,他自己是没有办法修改自己状态的,这时候操作系统修改以下它,将它从Ready_T_T移出来,并把All_T_T中的线程3状态修改为Running。
    总的来说就是,PCB用来表示线程/进程,然后操作系统通过PCB表来指挥谁上CPU,CPU就可以执行PCB对应的线程的任务执行序列了。
  • 2.从线程池的角度来说,管理线程可以使用线程池进行管理,线程池的核心配置包括,核心线程数量,任务队列容量,最大线程数,允许核心线程超时的时间

16.CAS机制

CAS是英文单词Compare And Swap的缩写,翻译过来就是比较并替换

CAS机制当中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B

更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。

具体的实现逻辑底层可以看一个案例:

在这里插入图片描述
从思想上来说,Synchronized属于悲观锁,悲观地认为程序中的并发情况严重,所以严防死守。CAS属于乐观锁,乐观地认为程序中的并发情况不那么严重,所以让线程不断去尝试更新。

在Java中,为了减少对系统的消耗,并且保证对某个变量的原子性操作,常用Atomic类实现,其实这些类底层都是基于CAS机制实现的。

使用CAS机制一般有如下的缺点:
CAS的缺点:

1.CPU开销较大
在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很大的压力。

2.不能保证代码块的原子性
CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用Synchronized了。

CAS机制如何解决ABA问题的?

首先来描述一下什么是ABA问题:
ABA问题是CAS机制中出现的一个问题,他的描述是这样的。我们直接画一张图来演示,
在这里插入图片描述

什么意思呢? 就是说一个线程把数据A变为了B,然后又重新变成了A。此时另外一个线程读取的时候,发现A没有变化,就误以为是原来的那个A。这就是有名的ABA问题。 ABA问题会带来什么后果呢?我们举个例子。

一个小偷,把别人家的钱偷了之后又还了回来,还是原来的钱吗,你老婆出轨之后又回来,还是原来的老婆吗?ABA问题也一样,如果不好好解决就会带来大量的问题。最常见的就是资金问题,也就是别人如果挪用了你的钱,在你发现之前又还了回来。但是别人却已经触犯了法律。

如何去解决这个ABA问题呢,就是使用大家常说的AtomicStampedReference
这是一个带时间戳的类,除了他之外,还有一个 AtomicMarkableReference 类,这是一个带版本号的类,其实无论记录下时间戳或者版本号,根本目的就是为了标记是否被使用过了。

17.锁的几种状态(无锁、偏向锁、轻量级锁、重量级锁)

偏向锁:其实就是你家里只有一个饭碗,只有你一个人在吃饭的时候,无需和别人竞争饭碗,你直接拿来吃就行了。
轻量级锁:假设你家里有两个人吃饭了,但是只有一个碗,并且两个人都不是很饿,你正在吃着了,你妈就可以等你吃完了,再拿这个碗去吃,可以交替执行任务。
重量级锁:假如你家里就只有一个碗,并且有两个人要吃饭,那么你们两个人就必须得抢饭碗才行,就是多个线程同时抢占资源。

在jdk1.6之后对synchronized关键字做了很大的修改,就是允许他从偏向锁向上升级。注意一点的是:锁的升级的不可逆的!升级到重量级锁之后就不能再返回轻量级的。

升级过程的大概过程描述:
jdk1.6对synchronized进行了优化。在最开始的时候我们的这个锁是一个无锁状态的,他支持升级为偏向锁,获取到锁资源的这个线程,获取到这个锁资源的线程我会优先让他先去获取这个锁,如果他没有获取到这个锁,就会升级为一个轻量级的,一个CAS锁,就是一个乐观锁,乐观锁他是一个比较替换的过程,如果这个CAS没有设置成功的话,它会进行一个自旋,自旋到一定的次数之后就会升级为一个synchronized这样一个重量级的锁。这样的话就保证了她的一个性能的问题。

18.如何进行无锁化的编程

这个问题需要分类讨论:
1.针对于原子加(减)问题,一般来说可以使用原子类中的加减(Atomic)
2.对于只有一个生产者和一个消费者的情形,那么可以做到免锁访问环形缓冲区,多个线程也不怕,利用队列(queue)去做
3.还可以使用CAS去实现,他本来就是一个乐观锁,但是操作起来还是挺困难的,在单生产者多消费者或者多生产者单消费者的情形下使用比较多。

19.如何在主线程中捕获到子线程的异常

首先要说明的是,线程的设计理念是:线程自己的异常就由线程本身处理,不应该委托外部进行处理。

确实想要知道子线程执行过程中发生过那些异常,可以使用下边的两种方法来解决:

1.可以使用java中提供的ThreadGroup这个类来解决,捕获到的异常在uncaughtException中进行处理。

public class ThreadTest extends ThreadGroup{

    private ThreadTest(){
        super("ThreadTest");
    }

    public static void main(String[] args) {
        new Thread(new ThreadTest(),new Runnable() {//传入继承ThreadGroup的类对象
            @Override
            public void run() {
                throw new NullPointerException();//只能抛出unchecked异常
            }
        }).start();
    }

    public   void   uncaughtException(Thread   thread,   Throwable   exception)
    {
        /**
         * 当线程抛出unckecked异常时,系统会自动调用该函数,但是是在抛出异常的线程内执行
         */
        System.out.println(thread.getId());
        exception.printStackTrace();//example,   print   stack   trace
    }
}

2.还有一种编程上的处理方式可以借鉴,即,有时候主线程的调用方可能只是想知道子线程执行过程中发生过哪些异常,而不一定会处理或是立即处理,那么发起子线程的方法可以把子线程抛出的异常实例收集起来作为一个Exception的List返回给调用方,由调用方来根据异常情况决定如何应对。不过要特别注意的是,此时子线程早以终结。

20.什么是happened-before原则?

(参考:happen-before原则

JMM(Java内存模型)可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。

具体的定义为:
1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。

具体的规则:
(1)程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
(2)监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
(3)volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
(4)传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
(5)start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
(6)Join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
(7)程序中断规则:对线程interrupted()方法的调用先行于被中断线程的代码检测到中断时间的发生。
(8)对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的finalize()方法的开始。
在这里插入图片描述
利用程序顺序规则(规则1)存在三个happens-before关系:

1.A happens-before B;
2. B happens-before C;
3.A happens-before C。

这里的第三个关系是利用传递性进行推论的。这里的第三个关系是利用传递性进行推论的。

A happens-before B,定义1要求A执行结果对B可见,并且A操作的执行顺序在B操作之前,但与此同时利用定义中的第二条,A,B操作彼此不存在数据依赖性,两个操作的执行顺序对最终结果都不会产生影响,在不改变最终结果的前提下,允许A,B两个操作重排序,即happens-before关系并不代表了最终的执行顺序。

21.ThreadLocal关键字的理解

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值