• 如果您觉得本站非常有看点,那么赶紧使用Ctrl+D 收藏吧

小滴课堂并发与多线程相关面试题总结

互联网 diligentman 5天前 4次浏览

1. 什么是进程、线程、协程,他们之间的关系是怎样的?

  • 进程:
    • 本质上是一个独立执行的程序,进程是操作系统进行资源分配和调度的基本概念,操作系统进行资源分配和调度的一个独立单位。
  • 线程:
    • 操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一个进程中可以并发多个线程,每条线程执行不同的任务,切换受系统控制。
  • 协程:
    • 又称为微线程,是一种用户态的轻量级线程,协程不像线程和进程需要进行系统内核上的上下文切换,协程的上下文切换是由用户自己决定的,有自己的上下文,所以说是轻量级的线程,也称之为用户级别的线程就叫协程,一个线程可以多个协程,线程进程都是同步机制,而协程则是异步 。Java的原生语法中并没有实现协程,目前python、Lua和GO等语言支持
  • 关系:
    • 一个进程可以有多个线程,它允许计算机同时运行两个或多个程序。线程是进程的最小执行单位,CPU的调度切换的是进程和线程,进程和线程多了之后调度会消耗大量的CPU,CPU上真正运行的是线程,线程可以对应多个协程:

小滴课堂并发与多线程相关面试题总结

2. 说下并发和并行的区别,并举例说明

  • 并发 concurrency:
    • 一核CPU,模拟出来多条线程,快速交替执行
  • 并行 parallellism:
    • 多核CPU ,多个线程可以同时执行
    • eg: 线程池!
  • 并发指在一段时间内宏观上去处理多个任务。并行指同一个时刻,多个任务确实真的同时运行。

举例:

#### 并发:是一心多用,听课和看电影,但是CPU大脑只有一个,所以轮着来

#### 并行:火影忍者中的影分身,有多个你出现,可以分别做不同的事情

3. java实现多线程有哪几种方式,有什么不同,比较常用哪种?

3.1 继承Thread

  • 继承Thread,重写里面run()方法,创建实例,执行start
  • 优点:代码编写最简单直接操作
  • 缺点:没返回值,继承一个类后,没法继承其他的类,拓展性差
public class ThreadDemo1 extends Thread {
    @Override
    public void run() {
        System.out.println("继承Thread实现多线程,名称:"+Thread.currentThread().getName());
    }
}

public static void main(String[] args) {
      ThreadDemo1 threadDemo1 = new ThreadDemo1();
      threadDemo1.setName("demo1");
      // 执行start
      threadDemo1.start();
      System.out.println("主线程名称:"+Thread.currentThread().getName());
}

3.2 实现Runnable接口

  • 自定义类实现Runnable,实现里面run()方法,创建Thread类,使用Runnable接口的实现对象作为参数传递给Thread对象,调用Strat方法。
  • 优点:线程类可以实现多个几接口,可以再继承一个类
  • 缺点:没返回值,不能直接启动,需要通过构造一个Thread实例传递进去启动
public class ThreadDemo2 implements Runnable {
    @Override
    public void run() {
        System.out.println("通过Runnable实现多线程,名称:"+Thread.currentThread().getName());
    }
}

public static void main(String[] args) {
        ThreadDemo2 threadDemo2 = new ThreadDemo2();
        Thread thread = new Thread(threadDemo2);
        thread.setName("demo2");
    	// start线程执行
        thread.start();
        System.out.println("主线程名称:"+Thread.currentThread().getName());
}

// JDK8之后采用lambda表达式
public static void main(String[] args) {
    Thread thread = new Thread(() -> {
        System.out.println("通过Runnable实现多线程,名称:"+Thread.currentThread().getName());
    });
    thread.setName("demo2");
    // start线程执行
    thread.start();
    System.out.println("主线程名称:"+Thread.currentThread().getName());
}

3.3 实现Callable接口

  • 创建callable接口的实现类,并实现call()方法,结合FutureTask类包装Callable对象,实现多线程。
  • 优点:有返回值,拓展性也高
  • 缺点:jdk5以后才支持,需要重写call()方法,结合多个类比如FutureTask和Thread类
public class MyTask implements Callable<Object> {
    @Override
    public Object call() throws Exception {
        System.out.println("通过Callable实现多线程,名称:"+Thread.currentThread().getName());
        return "这是返回值";
    }
}

public static void main(String[] args) {
    	// JDK1.8 lambda表达式
        FutureTask<Object> futureTask = new FutureTask<>(() -> {
          System.out.println("通过Callable实现多线程,名称:" +
                        			Thread.currentThread().getName());
            return "这是返回值";
        });

     	// MyTask myTask = new MyTask();
		// FutureTask<Object> futureTask = new FutureTask<>(myTask);
        // FutureTask继承了Runnable,可以放在Thread中启动执行
        Thread thread = new Thread(futureTask);
        thread.setName("demo3");
    	// start线程执行
        thread.start();
        System.out.println("主线程名称:"+Thread.currentThread().getName());
        try {
            // 获取返回值
            System.out.println(futureTask.get());
        } catch (InterruptedException e) {
            // 阻塞等待中被中断,则抛出
            e.printStackTrace();
        } catch (ExecutionException e) {
            // 执行过程发送异常被抛出
            e.printStackTrace();
        }
}

3.4 通过线程池创建线程

  • 自定义Runnable接口,实现run方法,创建线程池,调用执行方法并传入对象
  • 优点:安全高性能,复用线程
  • 缺点: jdk5后才支持,需要结合Runnable进行使用
public class ThreadDemo4 implements Runnable {
    @Override
    public void run() {
        System.out.println("通过线程池+runnable实现多线程,名称:" +
                           Thread.currentThread().getName());
    }
}

public static void main(String[] args) {
    	// 创建线程池
        ExecutorService executorService = Executors.newFixedThreadPool(3);
        for(int i=0;i<10;i++){
            // 线程池执行线程任务
            executorService.execute(new ThreadDemo4());
        }
        System.out.println("主线程名称:"+Thread.currentThread().getName());
        // 关闭线程池
        executorService.shutdown();
}
  • 一般常用的Runnable 和 第四种线程池+Runnable,简单方便扩展,和高性能 (池化的思想)

3.5 Runable Callable Thread 三者区别?

  • Thread是一个抽象类,只能被继承,而Runable Callable是接口,需要实现接口中的方法
  • 继承Thread重写run()方法,实现Runable接口需要实现run()方法,而Callable是需要实现call()方法
  • Thread和Runable 没有返回值,Callable 有返回值
  • 实现Runable 接口的类不能直接调用start()方法,需要new 一个Thread并发该实现类放入Thread,再通过新建的Thread实例来调用start()方法。
  • 实现Callable 接口的类需要借助FutureTask(将该实现类放入其中),再将FutureTask实例放入Thread,再通过新建的Thread实例来调用start()方法。获取返回值只需要借助FutureTask实例调用get()方法即可!

4. 线程的几个状态(生命周期)?

线程有几个状态(6个)!

public enum State {
    /**
     * 线程新生状态
     */
    NEW,
    
    /**
     * 线程运行中
     */
    RUNNABLE,
    
    /**
     * 线程阻塞状态
     */
    BLOCKED,
    
    /**
     * 线程等待状态,死等
     */
    WAITING,
    
    /**
     * 线程超时等待状态,超过一定时间就不再等
     */
    TIMED_WAITING,
    
    /**
     * 线程终止状态,代表线程执行完毕
     */
    TERMINATED;
}

5. 线程状态转换的相关方法:sleep/yield/join wait/notify/notifyAll

Tread下的方法

##### sleep()
    属于线程Thread的方法,让线程暂缓执行,等待预计时间之后再恢复
    交出CPU使用权,《不会释放锁》,抱着锁睡觉!
    进入超时等待状态TIME_WAITGING,睡眠结束变为就绪Runnable
    
##### yield()
    属于线程Thread的方法,暂停当前线程的对象,去执行其他线程
    交出CPU使用权,《不会释放锁》,和sleep类似
    作用:让相同优先级的线程轮流执行,但是不保证一定轮流
    注意:不会让线程进入阻塞状态BLOCKED,直接变为就绪Runnable,只需要重新获得CPU使用权
    
##### join()
    属于线程Thread的方法,在主线程上运行调用该方法,会让主线程休眠,
    《不会释放锁》 让调用join方法的线程先执行完毕,再执行其他线程
    类似让救护车警车优先通过!!

Object下的方法

##### wait()
    属于Object的方法,当前线程调用对象的wait方法,
    《会释放锁》,进入线程的等待队列
    需要依靠notify或者notifyAll唤醒,或者wait(timeout)时间自动唤醒
    
##### notify()
    属于Object的方法
    唤醒在对象监视器上等待的单个线程,《随机唤醒》
    
##### notifyAll()
    属于Object的方法
    唤醒在对象监视器上等待的全部线程,《全部唤醒》

线程状态转换流程图

小滴课堂并发与多线程相关面试题总结

6. Java中可以有哪些方法来保证线程安全?

  • 加锁:比如synchronize/ReentrantLock
  • 使用volatile声明变量,轻量级同步,不能保证原子性(需要解释)
  • 使用线程安全类,例如原子类 AtomicXXX
  • 使用线程安全集合容器,例如:CopyOnWriteArrayList/ConcurrentHashMap
  • ThreadLocal本地私有变量/信号量Semaphore等

7. 是否了解volatile关键字?能否解释下它和synchronized有什么区别?

线程安全行:

线程安全性包括两个方面,①可见性,②原子性

volatile特性

  • 参考文章: volatile关键字

  • volatile保证线程可见性案例:使用Volatile关键字的案例分析

  • 源码分析文章参考:java同步系列之volatile解析

通俗来说就是,线程A对一个volatile变量的修改,对于其它线程来说是可见的,即线程每次获取volatile变量的值都是最新的。

二者对比

  • volatile是轻量级的synchronized,保证了共享变量的可见性,被volatile关键字修饰的变量,如果值发生了变化,其他线程立刻可见,避免出现脏读现象!
  • volatile轻量级,只能修饰变量。synchronized重量级,还可修饰方法
  • volatile只能保证数据的可见性,不能用来同步,因为多个线程并发访问volatile修饰的变量不会阻塞
    synchronized不仅保证可见性,而且还保证原子性,因为,只有获得了锁的线程才能进入临界区,从而保证临界区中的所有语句都全部执行。多个线程争抢synchronized锁对象时,会出现阻塞
  • volatile:保证可见性,但是不能保证原子性
  • synchronized:保证可见性,也保证原子性

使用场景

对变量的写操作不依赖当前值,如多线程下执行a++,是无法通过volatile保证结果原子性的;

volatile int i = 0;并且大量线程调用i的自增操作,那么volatile可以保证变量的安全吗?

不可以保证!,volatile不能保证变量操作的原子性!

  • 自增操作包括三个步骤,分别是:读取,加一,写入,由于这三个子操作的原子性不能被保证,那么n个线程总共调用ni++的操作后,最后的i的值并不是大家想的n,而是一个比n小的数!

  • 解释

    • 比如A线程执行自增操作,刚读取到i的初始值0,然后就被阻塞了!
    • B线程现在开始执行,还是读取到i的初始值0,执行自增操作,此时i的值为1
    • 然后A线程阻塞结束,对刚才拿到的0执行加1与写入操作,执行成功后,i的值被写成1了!
    • 我们预期输出2,可是输出的是1,输出比预期小!
  • 代码实例:

    public class VolatileTest {
        public volatile int i = 0;
     
        public void increase() {
            i++;
        }
     
        public static void main(String args[]) throws InterruptedException {
            List<Thread> threadList = new ArrayList<>();
            VolatileTest test = new VolatileTest();
            for (int j = 0; j < 10000; j++) {
                Thread thread = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        test.increase();
                    }
                });
                thread.start();
                threadList.add(thread);
            }
     
            // 等待所有线程执行完毕
            for (Thread thread : threadList) {
                thread.join();
            }
            System.out.print(test.i);// 输出9995
        }
    }
    

    总结

    volatile不需要加锁,因此不会造成线程的阻塞,而且比synchronized更轻量级,而synchronized可能导致线程的阻塞volatile由于禁止了指令重排,所以JVM相关的优化没了,效率会偏弱!

##### JAVA内存模型简称 JMM
	   JMM规定所有的变量存在在主内存,每个线程有自己的工作内存,线程对变量的操作都在工作内存中进行,不能直接对主内存就行操作。
	   使用volatile修饰变量,每次读取前必须从主内存属性最新的值,每次写入需要立刻写到主内存中,volatile关键字修修饰的变量随时看到的自己的最新值,假如线程1对变量v进行修改,那么线程2是可以马上看见!

小滴课堂并发与多线程相关面试题总结

8. volatile可以避免指令重排,能否解释下什么是指令重排?

JVM在编译java代码或者CPU执行JVM字节码时,对现有的指令进行重新排序,主要目的是为了优化运行效率(不改变程序结果的前提)

int a = 3;     // step:1
int b = 4;     // step:2
int c =5;      // step:3 
int h = a*b*c; // step:4

定义顺序: 1,2,3,4
计算顺序: 1,3,2,42,1,3,4 结果都是一样的
  • 虽然指令重排序可以提高执行效率,但是多线程上可能会影响结果,有什么解决办法?
  • 解决办法:内存屏障(了解即可~)
    • 内存屏障是屏障指令,使CPU对屏障指令之前和之后的内存操作执行结果的一种约束!

扩展:现行发生原则happens-before(了解即可~)

volatile的内存可见性就体现了先行发生原则!

9. 介绍一下并发编程三要素?

  • 原子性
  • 有序性
  • 可见性

9.1 原子性

  • 原子性:
    • 一个不可再被分割的最小颗粒,原子性指的是一个或多个操作要么全部执行成功要么全部执行失败,期间不能被中断,也不存在上下文切换,线程切换会带来原子性的问题!
int num = 1; // 原子操作
num++;       // 非原子操作,从主内存读取num到线程工作内存,进行+1,再把num写回到主内存, 
			 // 除非用原子类:即,java.util.concurrent.atomic里的原子变量类

// 解决办法是可以用synchronized 或 Lock(比如ReentrantLock) 来把这个多步操作“变成”原子操作
// 这里不能使用volatile,前面有说到:对变量的写操作不依赖当前值,如多线程下执行a++,是无法通过volatile保证结果原子性的
public class XdTest {
    
    // 方式1:使用原子类
    // AtomicInteger  num = 0;// 这种方式的话++操作就可以保证原子性了,而不需要再加锁了
    private int num = 0;
    
    // 方式2:使用lock,每个对象都是有锁,只有获得这个锁才可以进行对应的操作
    Lock lock = new ReentrantLock();
    public  void add1(){
        lock.lock();
        try {
            num++;
        }finally {
            lock.unlock();
        }
    }
    
    // 方式3:使用synchronized,和上述是一个操作,这个是保证方法被锁住而已,上述的是代码块被锁住
    public synchronized void add2(){
        num++;
    }
}

解决核心思想:把一个方法或者代码块看做一个整体,保证是一个不可分割的整体

9.2 有序性

  • 有序性:
    • 程序执行的顺序按照代码的先后顺序执行,因为处理器可能会对指令进行重排序JVM在编译java代码或者CPU执行JVM字节码时,对现有的指令进行重新排序,主要目的是优化运行效率(不改变程序结果的前提)
int a = 3;     // step:1
int b = 4;     // step:2
int c =5;      // step:3 
int h = a*b*c; // step:4

定义顺序: 1,2,3,4
计算顺序: 1,3,2,42,1,3,4 结果都是一样的(单线程情况下)
指令重排序可以提高执行效率,但是多线程上可能会影响结果!

假如下面的场景:

// 线程1
before();// 处理初始化工作,处理完成后才可以正式运行下面的run方法
flag = true; // 标记资源处理好了,如果资源没处理好,此时程序就可能出现问题
// 线程2
while(flag){
    run(); // 执行核心业务代码
}

// -----------------指令重排序后,导致顺序换了,程序出现问题,且难排查-----------------

// 线程1
flag = true; // 标记资源处理好了,如果资源没处理好,此时程序就可能出现问题
// 线程2
while(flag){
    run(); // 执行核心业务代码
}
before();// 处理初始化工作,处理完成后才可以正式运行下面的run方法

9.3 可见性

  • 可见性:
    • 一个线程A对共享变量的修改,另一个线程B能够立刻看到!
// 线程 A 执行
int num = 0;
// 线程 A 执行
num++;
// 线程 B 执行
System.out.print("num的值:" + num);

线程A执行 i++ 后再执行线程B,线程B可能有2个结果,可能是01

因为i++ 在线程A中执行运算,并没有立刻更新到主内存当中,而线程B就去主内存当中读取并打印,此时打印的就是0;也可能线程A执行完成更新到主内存了,线程B的值是1

所以需要保证线程的可见性:
synchronized、lock和volatile 能够保证线程可见性

volatile保证线程可见性案例:使用Volatile关键字的案例分析

10. Java里面有哪些锁?分别解释下

乐观锁/悲观锁

  • 悲观锁:
    • 当线程去操作数据的时候,总认为别的线程会去修改数据,所以它每次拿数据的时候总会上锁,别的线程去拿数据的时候就会阻塞,比如synchronized
  • 乐观锁:
    • 每次去拿数据的时候都认为别人不会修改,更新的时候会判断是别人是否回去更新数据,通过版本来判断,如果数据被修改了就拒绝更新,比如CAS是乐观锁,但严格来说并不是锁,通过原子性来保证数据的同步,比如说数据库的乐观锁,通过版本控制来实现,CAS不会保证线程同步,乐观的认为在数据更新期间没有其他线程影响
  • 小结:悲观锁适合写操作多的场景,乐观锁适合读操作多的场景,乐观锁的吞吐量会比悲观锁大!

公平锁/非公平锁

  • 公平锁:
    • 指多个线程按照申请锁的顺序来获取锁,简单来说 如果一个线程组里,能保证每个线程都能拿到锁 比如ReentrantLock(底层是同步队列FIFO: First Input First Output来实现)
  • 非公平锁:
    • 获取锁的方式是随机获取的,保证不了每个线程都能拿到锁,也就是存在有线程饿死,一直拿不到锁,比如synchronized、ReentrantLock
  • 小结:非公平锁性能高于公平锁,更能重复利用CPU的时间。ReentrantLock中可以通过构造方法指定是否为公平锁,默认为非公平锁!synchronized无法指定为公平锁,一直都是非公平锁。

可重入锁/不可重入锁

  • 可重入锁:
    • 也叫递归锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁。一个线程获取锁之后再尝试获取锁时会自动获取锁,可重入锁的优点是避免死锁。
  • 不可重入锁:
    • 若当前线程执行某个方法已经获取了该锁,那么在方法中尝试再次获取锁时,就会获取不到被阻塞
  • 小结:可重入锁能一定程度的避免死锁 synchronized、ReentrantLock都是可重入锁

独占锁/共享锁

  • 独享锁,是指锁一次只能被一个线程持有。

    • 也叫X锁/排它锁/写锁/独享锁:该锁每一次只能被一个线程所持有,加锁后任何线程试图再次加锁的线程会被阻塞,直到当前线程解锁。例子:如果 线程A 对 data1 加上排他锁后,则其他线程不能再对 data1 加任何类型的锁,获得独享锁的线程即能读数据又能修改数据!
  • 共享锁,是指锁一次可以被多个线程持有。

    • 也叫S锁/读锁,能查看数据,但无法修改和删除数据的一种锁,加锁后其它用户可以并发读取、查询数据,但不能修改,增加,删除数据,该锁可被多个线程所持有,用于资源数据共享!

ReentrantLock和synchronized都是独享锁,ReadWriteLock的读锁是共享锁,写锁是独享锁

互斥锁/读写锁

与独享锁/共享锁的概念差不多,是独享锁/共享锁的具体实现。

ReentrantLock和synchronized都是互斥锁,ReadWriteLock是读写锁

自旋锁

  • 自旋锁:
    • 一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环,任何时刻最多只能有一个执行单元获得锁。
    • 不会发生线程状态的切换,一直处于用户态,减少了线程上下文切换的消耗,缺点是循环会消耗CPU。
  • 常见的自旋锁:TicketLock,CLHLock,MSCLock

死锁

  • 死锁:
    • 两个或两个以上的线程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法让程序进行下去!

小滴课堂并发与多线程相关面试题总结

下面三种是Jvm为了提高锁的获取与释放效率而做的优化 针对Synchronized的锁升级,锁的状态是通过对象监视器在对象头中的字段来表明,是不可逆的过程

  • 偏向锁:
    • 一段同步代码一直被一个线程所访问,那么该线程会自动获取锁,获取锁的代价更低!
  • 轻量级锁:
    • 当锁是偏向锁的时候,被其他线程访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,但不会阻塞,且性能会高点!
  • 重量级锁:
    • 当锁为轻量级锁的时候,其他线程虽然是自旋,但自旋不会一直循环下去,当自旋一定次数的时候且还没有获取到锁,就会进入阻塞,该锁升级为重量级锁,重量级锁会让其他申请的线程进入阻塞,性能也会降低!

11. 写个多线程死锁的例子

线程在获得了锁A并且没有释放的情况下去申请锁B,这时另一个线程已经获得了锁B,在释放锁B之前又要先获得锁A,因此闭环发生,陷入死锁循环:

public class DeadLockDemo {
    private static String locka = "locka";
    private static String lockb = "lockb";
    
    public void methodA(){
        synchronized (locka){
            System.out.println("我是A方法中获得了锁A "+Thread.currentThread().getName() );
            // 让出CPU执行权,不释放锁
            try {
                Thread.sleep(2000);// sleep不释放锁
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized(lockb){
                System.out.println("我是A方法中获得了锁B "+Thread.currentThread().getName() );
            }
        }
    }
    
    public void methodB(){
        synchronized (lockb){
            System.out.println("我是B方法中获得了锁B "+Thread.currentThread().getName() );
            // 让出CPU执行权,不释放锁
            try {
                Thread.sleep(2000);// sleep不释放锁
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized(locka){
                System.out.println("我是B方法中获得了锁A "+Thread.currentThread().getName() );
            }
        }
    }
    
    public static void main(String [] args){
        System.out.println("主线程运行开始运行:"+Thread.currentThread().getName());
        DeadLockDemo deadLockDemo = new DeadLockDemo();
        new Thread(()->{
            deadLockDemo.methodA();
        }).start();
        new Thread(()->{
            deadLockDemo.methodB();
        }).start();
        System.out.println("主线程运行结束:"+Thread.currentThread().getName());
    }
}

死锁的4个必要条件:

  • 互斥条件:资源不能共享,只能由一个线程使用!
  • 请求与保持条件:线程已经获得一些资源,但因请求其他资源发生阻塞,对已经获得的资源保持不释放!
  • 不可抢占:有些资源是不可强占的,当某个线程获得这个资源后,系统不能强行回收,只能由线程使用完自己释放!
  • 循环等待条件:多个线程形成环形链,每个都占用对方申请的下个资源!

只要发生死锁,上面的条件都成立,只要一个不满足,就不会发生死锁

12. 设计一个简单的不可重入锁

不可重入锁:若当前线程执行某个方法已经获取了该锁,那么在方法中尝试再次获取锁时,就会获取不到被阻塞!

public class UnreentrantLock {
    private boolean isLocked = false;
	
    // 加锁方法
    public synchronized void lock() throws InterruptedException {
        System.out.println("进入lock加锁 "+Thread.currentThread().getName());

        // 判断是否已经被锁,如果被锁则当前请求的线程进行等待
        while (isLocked){
            System.out.println("进入wait等待 "+Thread.currentThread().getName());
            wait();
        }
        // 如果还没被加锁,则进行加锁
        isLocked = true;
    }
    
    // 解锁方法
    public synchronized void unlock(){
        System.out.println("进入unlock解锁 "+Thread.currentThread().getName());
        isLocked = false;
        // 唤醒对象锁池里面的一个线程
        notify();
    }
}

public class Main {
    private UnreentrantLock unreentrantLock = new UnreentrantLock();
    // 加锁建议在try里面,解锁建议在finally
    public void  methodA(){
        try {
            unreentrantLock.lock();
            System.out.println("methodA方法被调用");
            // methodA()中嵌套调用methodB(),测试methodB()是否能获取锁的执行权
            methodB();
        }catch (InterruptedException e){
            e.fillInStackTrace();
        } finally {
            unreentrantLock.unlock();
        }
    }

    public void methodB(){
        try {
            unreentrantLock.lock();
            System.out.println("methodB方法被调用");
        }catch (InterruptedException e){
            e.fillInStackTrace();
        } finally {
            unreentrantLock.unlock();
        }
    }
    
    public static void main(String [] args){
        // 演示同一个线程下是否可冲入!(如果单线程都是不可重入的话,多线程下就不用说了~)
        new Main().methodA();
    }
}

// 同一个线程,重复获取锁失败,形成死锁,这个就是不可重入锁

小滴课堂并发与多线程相关面试题总结

13. 设计一个简单的可重入锁

可重入锁:也叫递归锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁

public class ReentrantLock {
    private boolean isLocked = false;

    // 用于记录是不是重入的线程
    private Thread lockedOwner = null;

    // 累计加锁次数,加锁一次累加1,解锁一次减少1
    private int lockedCount = 0;

    // 加锁方法
    public synchronized void lock() throws InterruptedException {
        System.out.println("进入lock加锁 "+Thread.currentThread().getName());

        // 获取当前线程
        Thread thread = Thread.currentThread();

        // 判断是否是同个线程获取锁, lockedOwner != thread引用地址的比较
        // 如果已经加锁,且当前线程不是之前加锁的线程则阻塞等待!
        while (isLocked && lockedOwner != thread ){
            System.out.println("进入wait等待 "+Thread.currentThread().getName());
            System.out.println("当前锁状态 isLocked = "+isLocked);
            System.out.println("当前count数量 lockedCount =  "+lockedCount);
            wait();
        }

        // 如果没有加锁,或者当前线程是之前加锁的线程,则:
        // 进行加锁,两次线程地址相同,加锁次数++
        isLocked = true;
        lockedOwner = thread;
        lockedCount++;
    }
    
    // 解锁方法
    public synchronized void unlock(){
        System.out.println("进入unlock解锁 "+Thread.currentThread().getName());

        // 获取当前线程
        Thread thread = Thread.currentThread();
        
        // 线程A加的锁,只能由线程A解锁,其他线程B不能解锁
        if(thread == this.lockedOwner){
            lockedCount--;
            if(lockedCount == 0){
                // 解锁
                isLocked = false;
                lockedOwner = null;
                // 唤醒对象锁池里面的一个线程
                notify();
            }
        }
    }
}

public class Main {
    //private UnreentrantLock unreentrantLock = new UnreentrantLock();
    private ReentrantLock reentrantLock = new ReentrantLock();

    // 加锁建议在try里面,解锁建议在finally
    public void  methodA(){
        try {
            reentrantLock.lock();
            System.out.println("methodA方法被调用");
            methodB();
        }catch (InterruptedException e){
            e.fillInStackTrace();
        } finally {
            reentrantLock.unlock();
        }
    }

    public void methodB(){
        try {
            reentrantLock.lock();
            System.out.println("methodB方法被调用");
        }catch (InterruptedException e){
            e.fillInStackTrace();
        } finally {
            reentrantLock.unlock();
        }
    }

    public static void main(String [] args){
        for(int i=0 ;i<10;i++){
            // 演示的是同个线程
            new Main().methodA();
        }
    }
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ttX91EiY-1613882316860)(小滴课堂并发与多线程相关面试题总结.assets/image-20210220161315283.png)]

14. 介绍下你对synchronized的理解?

源码分析文章参考:java同步系列之synchronized解析

  • synchronized是解决线程安全的问题,常用在同步普通方法、静态方法、代码块中使用!
  • synchronized非公平、可重入锁!
  • 每个对象有一个锁和一个等待队列,锁只能被一个线程持有,其他需要锁的线程需要阻塞等待。锁被释放后,对象会从队列中取出一个并唤醒,唤醒哪个线程是不确定的,不保证公平性

15. 解释下什么是CAS?以及ABA问题?

CAS全称:Compare and Swap 比较并交换

Unsafe实现原理,参考文章:java魔法类之Unsafe解析

  • CAS底层通过Unsafe类实现原子性操作,操作包含三个操作数:
    • 对象内存地址(V):
    • 预期原值(A):
    • 新值(B)
  • 理解方式1:比较当前工作内存中的值和主内存中的值,如果这个值是期望的,那么则执行交换操作!如果不是就一直循环!
  • 理解方式2:如果内存地址中的值与预期原值相匹配,那么处理器会自动将该地址的值更新为新值 ,若果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。

CAS属于乐观锁,性能较悲观锁有很大的提高!

AtomicXXX 等原子类底层就是CAS实现,一定程度比synchonized好,因为后者是悲观锁

小滴课堂并发与多线程相关面试题总结

小滴老师讲这块的时候,对于第一次接触CAS的萌新有些不好理解,这里我参考狂神老师在介绍CAS的时候的一些理解:以一个案例入手:

案例:

public class CASDemo { 
    // CAS compareAndSet : 比较并交换! 
    public static void main(String[] args) { 
        AtomicInteger atomicInteger = new AtomicInteger(2020); 
        
        // 期望、更新 
        // public final boolean compareAndSet(int expect, int update) 
        // 如果我期望的值达到了,那么就更新,否则,
        // 就不更新, CAS 是CPU的并发原语! 
        System.out.println(atomicInteger.compareAndSet(2020, 2021));// true
        System.out.println(atomicInteger.get());// 2021
        
        //atomicInteger.getAndIncrement()// 看底层如何实现 ++ 
        System.out.println(atomicInteger.compareAndSet(2020, 2021));// false
        System.out.println(atomicInteger.get());// 2021
    } 
}

我们来看一下getAndIncrement()方法的底层实现:

public class AtomicInteger extends Number implements java.io.Serializable {
    private static final long serialVersionUID = 6214790243416807050L;

    // UnSafe类,底层是调用C++:Java无法操作内存,所以这里借助C++来操作内存
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            // 获取内存偏移值valueOffset
            valueOffset = unsafe.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (Exception ex) { throw new Error(ex); }
    }

    // value被volatile修饰,避免指令重排,且保证线程可见性和有序性
    private volatile int value;
    
    ...

    public final int getAndIncrement() {
        // 参数
        // this: 当前对象
        // valueOffset:当前对象的内存偏移地址
        // 1:值
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }
    
    ...
}

大致了解UnSafe后,我们继续点进getAndIncrement()方法中,unsafe调用的getAndAddInt()方法查看:

// 位于UnSafe类中
// 参数:var1 当前对象,var2 当前对象的内存偏移地址,var4 值(1)
public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    // 这里用到了自旋锁:一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环,任何时刻最多只能有一个执行单元获得锁。
    do {
        // 获取内存地址中的原对象的值
        var5 = this.getIntVolatile(var1, var2);
    
    // 借助CAS比较并交换,来实现getAndIncrement()方法的自增+1功能!
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
    return var5;
}

...
   
// 调用C++,执行比较并交换
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

小滴课堂并发与多线程相关面试题总结

CAS : 比较当前工作内存中的值和主内存中的值,如果这个值是期望的,那么则执行操作!如果不是就

一直循环!

CAS的ABA问题?

狸猫换太子

小滴课堂并发与多线程相关面试题总结

public class CasAbaTest {

    // CAS compareAndSet : 比较并交换!
    public static void main(String[] args) {
        AtomicInteger atomicInteger = new AtomicInteger(2020);

        /*
         * 类似于我们平时写的SQL:乐观锁
         *
         * 如果某个线程在执行操作某个对象的时候,其他线程若操作了该对象,
         * 即使对象内容未发生变化,也需要告诉我。
         *
         * 期望、更新:
         * public final boolean compareAndSet(int expect, int update)
         * 如果我期望的值达到了,那么就更新,否则,就不更新,
         *									CAS 是CPU的并发原语!
         */

        // ============== 捣乱的线程 ==================
        System.out.println(atomicInteger.compareAndSet(2020, 2021));
        System.out.println(atomicInteger.get());
        System.out.println(atomicInteger.compareAndSet(2021, 2020));
        System.out.println(atomicInteger.get());

        // ============== 期望的线程 ==================
        System.out.println(atomicInteger.compareAndSet(2020, 6666));
        System.out.println(atomicInteger.get());
    }
}

输出结果:
true
2021
true
2020
true
6666 

上述案例中:假设我们期望的线程本来是需要将2020更换成6666,然而有一个捣乱的线程抢在期望线程之前执行,先把2020更换为了2021,然后又将2021更换回2020!

这样看上去当期望线程执行时,初始值仍为2020没有改变,但是实际上在捣乱线程中已经执行过2次更换操作了,而我们的期望线程并不知情!这就是ABA问题!

如何解决ABA问题?

本质上相当于采用乐观锁策略解决ABA问题!

public class CASDemo {
    /**
     * AtomicStampedReference 注意,
     * 如果泛型是一个包装类,就需要注意对象的引用问题
     * 正常在业务操作,这里面比较的都是一个个对象
     */
    // 参数1:初始值100
    // 参数2:初始对应的版本号 initialStamp=1
    static AtomicStampedReference<Integer> atomicStampedReference =
            new AtomicStampedReference<>(100,1);

    // CAS compareAndSet : 比较并交换!
    public static void main(String[] args) {
        // 线程A:
        new Thread(()->{
            // 线程执行时,先获得initialStamp版本号
            int stamp = atomicStampedReference.getStamp();

            System.out.println("A线程第1次拿到的版本号为:"+stamp);

            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            // cas比较并交换:100--->101
            atomicStampedReference.compareAndSet(
                    100,
                    101,
                    atomicStampedReference.getStamp(),// 获得最新版本号
                    // 更新版本号
                    atomicStampedReference.getStamp() + 1);

            System.out.println("A线程第2次拿到的版本号为:"
                    +atomicStampedReference.getStamp());

            // cas比较并交换:101--->100
            System.out.println("A线程第2次是否执行了CAS:" +
                    atomicStampedReference.compareAndSet(
                            101,
                            100,
                            atomicStampedReference.getStamp(),
                            atomicStampedReference.getStamp() + 1));

            System.out.println("A线程第3次拿到的版本号为:"
                    +atomicStampedReference.getStamp());
        },"A").start();

        // 乐观锁的原理相同!
        // 线程B:
        new Thread(()->{
            // 获得版本号
            int stamp = atomicStampedReference.getStamp();

            System.out.println("B线程第1次拿到的版本号为:"+stamp);
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            // cas比较并交换:100--->99
            System.out.println("B线程第1次是否执行了CAS:" +
                    atomicStampedReference.compareAndSet(
                            100,
                            99,
                            stamp,
                            stamp + 1));

            System.out.println("B线程第2次拿到的版本号为:"
                    +atomicStampedReference.getStamp());
        },"B").start();
    }
}

这样,在版本号initialStamp的限制下,每执行一次CAS,都会将版本号+1,这样即使出现了 “狸猫换太子” 情况,期望线程也能及时知道!

输出结果如下:

A线程第1次拿到的版本号为:1
B线程第1次拿到的版本号为:1
A线程第2次拿到的版本号为:2
A线程第2次是否执行了CAS:true
A线程第3次拿到的版本号为:3
B线程第1次是否执行了CAS:false
B线程第2次拿到的版本号为:3

总的来说,与MySQL的乐观锁表中加一个version字段原理相同!

注意:

Integer 使用了对象缓存机制,默认范围是 -128 ~ 127 ,推荐使用静态工厂方法 valueOf 获取对象实例,而不是 new,因为 valueOf 使用缓存,而 new 一定会创建新的对象分配新的内存空间;

下面是阿里巴巴开发手册的规范点:

小滴课堂并发与多线程相关面试题总结

所以上面的案例,如果使用大于-128-127范围的数字时候就会出现2个flase的情况!这里小伙伴一定要注意下~

16. 介绍下你对AQS的理解?

参考文章:AQS面试详解

AQS的全称为(AbstractQueuedSynchronizer)抽象的队列式同步器是除了java自带的synchronized关键字之外的锁机制。这个类在java.util.concurrent.locks包下面。

它是一个Java提高的底层同步工具类,比如CountDownLatch、ReentrantLock,Semaphore,ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的!

实现了AQS的锁有:自旋锁、互斥锁、读锁写锁、条件产量、信号量、栅栏都是AQS的衍生物!

17. ReentrantLock和synchronized的差别?

  • ReentrantLock和synchronized都是独占锁,可重入锁,悲观锁
  • synchronized:
    • 1、java内置关键字
    • 2、无法判断是否获取锁的状态只能是非公平锁
    • 3、加锁解锁的过程是隐式的,用户不用手动操作,优点是操作简单但显得不够灵活
    • 4、一般并发场景使用足够、可以放在被递归执行的方法上,且不用担心线程最后能否正确释放锁
  • ReentrantLock:
    • 1、是个Lock接口的实现类
    • 2、可以判断是否获取到锁,可以为公平锁也可以是非公平锁(默认)
    • 3、需要手动加锁和解锁,且解锁的操作尽量要放在finally代码块中,保证线程正确释放锁
    • 5、创建的时候通过传进参数true创建公平锁,如果传入的是false或没传参数则创建的是非公平锁
    • 6、底层是AQS的state和FIFO队列来控制加锁

18. ReentrantReadWriteLock和ReentrantLock有什么区别?

小滴课堂并发与多线程相关面试题总结

ReentrantReadWriteLock

1、读写锁接口ReadWriteLock接口的一个具体实现,实现了读写锁分离

2、支持公平和非公平,底层也是基于AQS实现

3、允许从写锁降级为读锁
流程:先获取写锁,然后获取读锁,最后释放写锁;但不能从读锁升级到写锁

4、重入:

  • 读锁后还可以获取读锁;

  • 获取了写锁之后既可以再次获取写锁又可以获取读锁

  • 读锁是共享的,写锁是独占的!读和读之间不会互斥,读和写、写和读、写和写之间才会互斥,主要是提升了读写的性能 !

ReentrantLock是独占锁且可重入的,相比synchronized而言功能更加丰富也更适合复杂的并发场景,但是也有弊端,假如有两个线程A/B访问数据,加锁是为了防止线程A在写数据, 线程B在读数据造成的数据不一致; 但线程A在读数据,线程C也在读数据,读数据是不会改变数据没有必要加锁,但是ReentrantLock还是加锁了,降低了程序的性能,所以就有了ReadWriteLock读写锁接口!

19. 是否了解阻塞队列BlockingQueue?

BlockingQueue阻塞队列

  • ArrayBlockingQueue,ArrayBlockingQueue
  • put方法用来向队尾存入元素,如果队列满,则阻塞
  • take方法用来从队首取元素,如果队列为空,则阻塞

BlockingQueue: juc包下的提供了线程安全的队列访问的接口,并发包下很多高级同步类的实现都是基于阻塞队列实现的!

  • 1、当阻塞队列进行插入数据时,如果队列已满,线程将会阻塞等待直到队列非满
  • 2、从阻塞队列读数据时,如果队列为空,线程将会阻塞等待直到队列里面是非空的时候

常见的阻塞队列

  • ArrayBlockingQueue:
    • 基于数组实现的一个阻塞队列,需要指定容量大小,FIFO先进先出顺序;
  • LinkedBlockingQueue:
    • 基于链表实现的一个阻塞队列,如果不指定容量大小,默认Integer.MAX_VALUE,FIFO先进先出顺序;
  • PriorityBlockingQueue:
    • 一个支持优先级的无界阻塞队列,默认情况下元素采用自然顺序升序排序,也可以自定义排序实现java.lang.Comparable接口;
  • DelayQueue:
    • 延迟队列在指定时间才能获取队列元素的功能队列头元素是最接近过期的元素,里面的对象必须实现 java.util.concurrent.Delayed 接口并实现CompareTo和getDelay方法;

扩展:你知道非阻塞队列ConcurrentLinkedQueue吗,它怎么实现线程安全的?

参考文章:Java并发编程之ConcurrentLinkedQueue详解

20. java里有哪些是常用的线程池?

使用线程池的好处:

重用存在的线程,减少对象创建销毁的开销,有效的控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞,且可以定时定期执行、单线程、并发数控制,配置任务过多任务后的拒绝策略等功能

类别:

  • newFixedThreadPool :
    • 一个定长线程池,可控制线程最大并发数
  • newCachedThreadPool:
    • 一个可缓存线程池
  • newSingleThreadExecutor:
    • 一个单线程化的线程池,用唯一的工作线程来执行任务
  • newScheduledThreadPool:
    • 一个定长线程池,支持定时/周期性任务执行

【阿里巴巴编码规范】 线程池不允许使用 Executors 去创建,要通过 ThreadPoolExecutor的方式原因?

	Executors创建的线程池底层也是调用 ThreadPoolExecutor,只不过使用不同的参数、队列、拒绝策略等
	如果使用不当,会造成资源耗尽问题

	直接使用ThreadPoolExecutor让使用者更加清楚线程池允许规则,常见参数的使用,避免风险

##### 常见的线程池问题:
    newFixedThreadPool和newSingleThreadExecutor: 
    队列使用LinkedBlockingQueue,队列长度为 Integer.MAX_VALUE,可能造成堆积,导致OOM

    newScheduledThreadPool和newCachedThreadPool:
    线程池里面允许最大的线程数是Integer.MAX_VALUE,可能会创建过多线程,导致OOM

ThreadPoolExecutor构造函数里面的参数,能否解释下各个参数的作用?

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)
  • corePoolSize核心线程数,线程池也会维护线程的最少数量,默认情况下核心线程会一直存活,即使没有任务也不会受存keepAliveTime控制!
    :在刚创建线程池时线程不会立即启动,到有任务提交时才开始创建线程并逐步线程数目达到corePoolSize

  • maximumPoolSize线程池维护线程的最大数量,超过将被阻塞!
    :当核心线程满,且阻塞队列也满时,才会判断当前线程数是否小于最大线程数,才决定是否创建新线程

  • keepAliveTime非核心线程的闲置超时时间,超过这个时间就会被回收,直到线程数量等于corePoolSize

  • unit:指定keepAliveTime的单位,如TimeUnit.SECONDS、TimeUnit.MILLISECONDS

  • workQueue线程池中的任务队列,常用的是 ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue

  • threadFactory创建新线程时使用的工厂

  • handler:RejectedExecutionHandler是一个接口且只有一个方法,线程池中的数量大于maximumPoolSize,对拒绝任务的处理策略,默认有4种策略:

    • AbortPolicy
    • CallerRunsPolicy
    • DiscardOldestPolicy
    • DiscardPolicy

程序员灯塔
转载请注明原文链接:小滴课堂并发与多线程相关面试题总结
喜欢 (0)