0%

面试准备系列之Java并发

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

有两种调度模型:分时调度模型和抢占式调度模型。

  • 分时调度模型是指让所有的线程轮流获得 CPU 的使用权,并且平均分配每个线程占用的 CPU 的时间片这个也比较好理解。

  • Java 虚拟机采用抢占式调度模型,是指优先让可运行池中优先级高的线程占用 CPU ,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用 CPU 。处于运行状态的线程会一直运行,直至它不得不放弃 CPU 。

线程的生命周期?

线程一共有五个状态,分别如下:

  • 新建(new):当创建Thread类的一个实例(对象)时,此线程进入新建状态(未被启动)。例如:Thread t1 = new Thread() 。
  • 可运行(runnable):线程对象创建后,其他线程(比如 main 线程)调用了该对象的 start 方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取 cpu 的使用权。例如:t1.start() 。
  • 运行(running):线程获得 CPU 资源正在执行任务(#run() 方法),此时除非此线程自动放弃 CPU 资源或者有优先级更高的线程进入,线程将一直运行到结束。

  • 死亡(dead):当线程执行完毕或被其它线程杀死,线程就进入死亡状态,这时线程不可能再进入就绪状态等待执行。

    • 自然终止:正常运行完 #run()方法,终止。
    • 异常终止:调用 #stop() 方法,让一个线程终止运行。
  • 堵塞(blocked):由于某种原因导致正在运行的线程让出 CPU 并暂停自己的执行,即进入堵塞状态。直到线程进入可运行(runnable)状态,才有机会再次获得 CPU 资源,转到运行(running)状态。阻塞的情况有三种:

    • 正在睡眠:调用 #sleep(long t) 方法,可使线程进入睡眠方式。
    • 正在等待:调用 #wait() 方法。调用 notify() 方法,回到就绪状态。
    • 被另一个线程所阻塞:调用 #suspend() 方法。(调用 #resume() 方法,就可以恢复。)

整体如下图所示:

  • 中间一行是线程的顺畅的执行过程的四个状态。其上下两侧,是存在对应的情况,达到阻塞状态和恢复执行的过程。
  • 有一点要注意,新建(new)和死亡(dead)是单向的状态,不可重复。

创建线程的方式及实现?

  • 方式一,继承 Thread 类创建线程类。
  • 方式二,通过 Runnable 接口创建线程类。
  • 方式三,通过 Callable 和 Future 创建线程。

创建线程的三种方式的对比:

  • 使用方式一
    • 优点:编写简单,如果需要访问当前线程,则无需使用 Thread#currentThread() 方法,直接使用 this 即可获得当前线程。
    • 缺点:线程类已经继承了 Thread 类,所以不能再继承其他父类。
  • 使用方式二、或方式三
    • 优点:
      • 线程类只是实现了 Runnable 接口或 Callable 接口,还可以继承其他类。
      • 可以使用线程池。
    • 缺点:
      • 编程稍微复杂,如果要访问当前线程,则必须使用Thread#currentThread() 方法。

如何使用 wait + notify 实现通知机制?

sleep、join、yield 方法有什么区别?

1)sleep 方法

在指定的毫秒数内,让当前正在执行的线程休眠(暂停执行),此操作受到系统计时器和调度程序精度和准确性的影响。让其他线程有机会继续执行,但它并不释放对象锁。也就是如果有synchronized 同步块,其他线程仍然不能访问共享数据。注意该方法要捕获异常。

比如有两个线程同时执行(没有 synchronized),一个线程优先级为MAX_PRIORITY ,另一个为 MIN_PRIORITY 。

  • 如果没有 sleep 方法,只有高优先级的线程执行完成后,低优先级的线程才能执行。但当高优先级的线程 #sleep(5000) 后,低优先级就有机会执行了。
  • 总之,sleep 方法,可以使低优先级的线程得到执行的机会,当然也可以让同优先级、高优先级的线程有执行的机会。

2)yield 方法

yield 方法和 sleep 方法类似,也不会释放“锁标志”,区别在于:

  • 它没有参数,即 yield 方法只是使当前线程重新回到可执行状态,所以执行yield 的线程有可能在进入到可执行状态后马上又被执行。
  • 另外 yield 方法只能使同优先级或者高优先级的线程得到执行机会,这也和 sleep 方法不同。

3)join 方法

Thread 的非静态方法 join ,让一个线程 B “加入”到另外一个线程 A 中。B运行完后,A才会继续执行。示例代码如下:

1
2
3
Thread t = new MyThread();
t.start();
t.join();

  • 保证当前线程停止执行,直到该线程所加入的线程 t 完成为止。然而,如果它加入的线程 t 没有存活,则当前线程不需要停止。

sleep(0) 有什么用途?

Thread#sleep(0) 方法,并非是真的要线程挂起 0 毫秒,意义在于这次调用 Thread#sleep(0) 方法,把当前线程确实的被冻结了一下,让其他线程有机会优先执行。Thread#sleep(0) 方法,是你的线程暂时放弃 CPU ,也就是释放一些未用的时间片给其他线程或进程使用,就相当于一个让位动作

你如何确保 main 方法所在的线程是 Java 程序最后结束的线程?

考点,就是 join 方法。

我们可以使用 Thread 类的 #join() 方法,来确保所有程序创建的线程在 main 方法退出前结束。

什么叫线程安全?

线程安全,是编程中的术语,指某个函数、函数库在多线程环境中被调用时,能够正确地处理多个线程之间的共享变量,使程序功能正确完成。

多线程同步和互斥有几种实现方法,都是什么?

1)线程同步
线程同步,是指线程之间所具有的一种制约关系,一个线程的执行依赖另一个线程的消息,当它没有得到另一个线程的消息时应等待,直到消息到达时才被唤醒。

线程间的同步方法,大体可分为两类:用户模式和内核模式。顾名思义:

  • 内核模式,就是指利用系统内核对象的单一性来进行同步,使用时需要切换内核态与用户态。内核模式下的方法有:
    事件
    • 事件
    • 信号量
    • 互斥量
  • 用户模式,就是不需要切换到内核态,只在用户态完成操作。用户模式下的方法有:
    • 原子操作(例如一个单一的全局变量)
    • 临界区

2)线程互斥
线程互斥,是指对于共享的进程系统资源,在各单个线程访问时的排它性。

  • 当有若干个线程都要使用某一共享资源时,任何时刻最多只允许一个线程去使用,其它要使用该资源的线程必须等待,直到占用资源者释放该资源。
  • 线程互斥可以看成是一种特殊的线程同步。

什么是 ThreadLocal 变量?

ThreadLocal ,是 Java 里一种特殊的变量。每个线程都有一个 ThreadLocal 就是每个线程都拥有了自己独立的一个变量,竞争条件被彻底消除了。

synchronized 的原理是什么?

volatile 实现原理

什么是死锁

是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。

产生死锁的必要条件:

  • 互斥条件:所谓互斥就是进程在某一时间内独占资源。
  • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  • 不剥夺条件:进程已获得资源,在末使用完之前,不能强行剥夺。
  • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

死锁的解决方法:

  • 撤消陷于死锁的全部进程。
  • 逐个撤消陷于死锁的进程,直到死锁不存在。
  • 从陷于死锁的进程中逐个强迫放弃所占用的资源,直至死锁消失。
  • 从另外一些进程那里强行剥夺足够数量的资源分配给死锁进程,以解除死锁状态。

什么是活锁?

活锁,任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试,失败,尝试,失败

死锁和活锁的区别?

活锁和死锁的区别在于,处于活锁的实体是在不断的改变状态,所谓的“活”,而处于死锁的实体表现为等待;活锁有可能自行解开,死锁则不能。

实际上,聪慧的胖友是不是已经发现,死锁就是悲观锁可能产生的结果,而活锁是乐观锁可能产生的结果。

什么是悲观锁

悲观锁,总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。

  • 传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。
  • 再比如 Java 里面的同步原语 synchronized 关键字的实现也是悲观锁。

什么是乐观锁

乐观锁,顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量。

  • 像数据库提供的类似于 write_condition 机制,其实都是提供的乐观锁。例如,version 字段(比较跟上一次的版本号,如果一样则更新,如果失败则要重复读-比较-写的操作)
  • 在 Java 中 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。

乐观锁的实现方式有哪些

  • 使用版本标识来确定读到的数据与提交时的数据是否一致。提交后修改版本标识,不一致时可以采取丢弃和再次尝试的策略。
  • Java 中的 Compare and Swap 即 CAS ,当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。

什么是Java Lock 接口?

什么是可重入锁(ReentrantLock)?

已经获取到锁的对象,再次申请获取锁,也可以申请成功,就是可重入锁。

synchronized 和 ReentrantLock 异同?

相同点:

  • 都实现了多线程同步和内存可见性语义。
  • 都是可重入锁。

不同点:

  • 同步实现机制不同
    • synchronized 通过 Java 对象头锁标记和 Monitor 对象实现同步。
    • ReentrantLock 通过CAS、AQS(AbstractQueuedSynchronizer)和 LockSupport(用于阻塞和解除阻塞)实现同步。
  • 可见性实现机制不同
    • synchronized 依赖 JVM 内存模型保证包含共享变量的多线程内存可见性。
    • ReentrantLock 通过 ASQ 的 volatile state 保证包含共享变量的多线程内存可见性。
  • 使用方式不同
    • synchronized 可以修饰实例方法(锁住实例对象)、静态方法(锁住类对象)、代码块(显示指定锁对象)。
    • ReentrantLock 显示调用 tryLock 和 lock 方法,需要在 finally 块中释放锁。
  • 功能丰富程度不同
    • synchronized 不可设置等待时间、不可被中断(interrupted)。
    • ReentrantLock 提供有限时间等候锁(设置过期时间)、可中断锁(lockInterruptibly)、condition(提供 await、condition(提供 await、signal 等方法)等丰富功能
  • 锁类型不同
    • synchronized 只支持非公平锁。
    • ReentrantLock 提供公平锁和非公平锁实现。当然,在大部分情况下,非公平锁是高效的选择。

在 synchronized 优化以前,它的性能是比 ReenTrantLock 差很多的,但是自从 synchronized 引入了偏向锁,轻量级锁(自旋锁)后,两者的性能就差不多了,在两种方法都可用的情况下,官方甚至建议使用 synchronized 。

并且,实际代码实战中,可能的优化场景是,通过读写分离,进一步性能的提升,所以使用 ReentrantReadWriteLock 。

ReadWriteLock 是什么

ReadWriteLock ,读写锁是,用来提升并发程序性能的锁分离技术的 Lock 实现类。可以用于 “多读少写” 的场景,读写锁支持多个读操作并发执行,写操作只能由一个线程来操作。

ReadWriteLock 对向数据结构相对不频繁地写入,但是有多个任务要经常读取这个数据结构的这类情况进行了优化。ReadWriteLock 使得你可以同时有多个读取者,只要它们都不试图写入即可。如果写锁已经被其他任务持有,那么任何读取者都不能访问,直至这个写锁被释放为止。

ReadWriteLock 对程序性能的提高主要受制于如下几个因素:

  1. 数据被读取的频率与被修改的频率相比较的结果。
  2. 读取和写入的时间
  3. 有多少线程竞争
  4. 是否在多处理机器上运行

什么是 Java 内存模型?

为什么代码会重排序?

在执行程序时,为了提供性能,处理器和编译器常常会对指令进行重排序,但是不能随意重排序,不是你想怎么排序就怎么排序,它需要满足以下两个条件:

  1. 在单线程环境下不能改变程序运行的结果。
  2. 存在数据依赖关系的不允许重排序

需要注意的是:重排序不会影响单线程环境的执行结果,但是会破坏多线程的执行语义。

什么是内存模型的 happens-before 呢?

SynchronizedMap 和 ConcurrentHashMap 有什么区别?

  • SynchronizedMap

    • 一次锁住整张表来保证线程安全,所以每次只能有一个线程来访为 map 。
  • ConcurrentHashMap

    • 使用分段锁来保证在多线程下的性能。ConcurrentHashMap 中则是一次锁住一个桶。ConcurrentHashMap 默认将 hash 表分为 16 个桶,诸如 get,put,remove 等常用操作只锁当前需要用到的桶。这样,原来只能一个线程进入,现在却能同时有 16 个写线程执行,并发性能的提升是显而易见的。【注意,这块是 JDK7 的实现。在 JDK8 中,具体的实现已经改变】

    • 另外 ConcurrentHashMap 使用了一种不同的迭代方式。在这种迭代方式中,当 iterator 被创建后集合再发生改变就不再是抛出 ConcurrentModificationException 异常,取而代之的是在改变时 new 新的数据从而不影响原有的数据,iterator 完成后再将头指针替换为新的数据 ,这样 iterator 线程可以使用原来老的数据,而写线程也可以并发的完成改变。

什么是 Executor 框架?

Executor 框架,是一个根据一组执行策略调用,调度,执行和控制的异步任务的框架。

无限制的创建线程,会引起应用程序内存溢出。所以创建一个线程池是个更好的的解决方案,因为可以限制线程的数量并且可以回收再利用这些线程。利用 Executor 框架,可以非常方便的创建一个线程池。

创建线程池的几种方式?

Java 类库提供一个灵活的线程池以及一些有用的默认配置,我们可以通过Executors 的静态方法来创建线程池。

Executors 创建的线程池,分成普通任务线程池,和定时任务线程池。

1)普通线程池

  • #newFixedThreadPool(int nThreads) 方法,创建一个固定长度的线程池。
    • 每当提交一个任务就创建一个线程,直到达到线程池的最大数量,这时线程规模将不再变化。
    • 当线程发生未预期的错误而结束时,线程池会补充一个新的线程。
  • #newCachedThreadPool() 方法,创建一个可缓存的线程池。
    • 如果线程池的规模超过了处理需求,将自动回收空闲线程。
    • 当需求增加时,则可以自动添加新线程。线程池的规模不存在任何限制。
  • #newSingleThreadExecutor() 方法,创建一个单线程的线程池。
    • 它创建单个工作线程来执行任务,如果这个线程异常结束,会创建一个新的来替代它。
    • 它的特点是,能确保依照任务在队列中的顺序来串行执行。

2)定时任务线程池

  • #newScheduledThreadPool(int corePoolSize) 方法,创建了一个固定长度的线程池,而且以延迟或定时的方式来执行任务,类似 Timer 。
  • #newSingleThreadExecutor() 方法,创建了一个固定长度为 1 的线程池,而且以延迟或定时的方式来执行任务,类似 Timer 。

ThreadPoolExecutor 有哪些拒绝策略?

ThreadPoolExecutor 默认有四个拒绝策略:

  • ThreadPoolExecutor.AbortPolicy() ,直接抛出异常 RejectedExecutionException 。
  • ThreadPoolExecutor.CallerRunsPolicy() ,直接调用 run 方法并且阻塞执行。
  • ThreadPoolExecutor.DiscardPolicy() ,直接丢弃后来的任务。
  • ThreadPoolExecutor.DiscardOldestPolicy() ,丢弃在队列中队首的任务。

如果我们有需要,可以自己实现 RejectedExecutionHandler 接口,实现自定义的拒绝逻辑。

什么是 Callable、Future、FutureTask ?

1)Callable

Callable 接口,类似于 Runnable ,从名字就可以看出来了,但是Runnable 不会返回结果,并且无法抛出返回结果的异常,而 Callable 功能更强大一些,被线程执行后,可以返回值,这个返回值可以被 Future 拿到,也就是说,Future 可以拿到异步执行任务的返回值。

简单来说,可以认为是带有回调的 Runnable 。

2)Future

Future 接口,表示异步任务,是还没有完成的任务给出的未来结果。所以说 Callable 用于产生结果,Future 用于获取结果。

3)FutureTask

在 Java 并发程序中,FutureTask 表示一个可以取消的异步运算。

  • 它有启动和取消运算、查询运算是否完成和取回运算结果等方法。只有当运算完成的时候结果才能取回,如果运算尚未完成 get 方法将会阻塞。
  • 一个 FutureTask 对象,可以对调用了 Callable 和 Runnable 的对象进行包装,由于 FutureTask 也是继承了 Runnable 接口,所以它可以提交给 Executor 来执行。

讲讲线程池的实现原理

什么是阻塞队列?有什么适用场景?

阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作是:

  • 在队列为空时,获取元素的线程会等待队列变为非空。
  • 当队列满时,存储元素的线程会等待队列可用。

阻塞队列常用于生产者和消费者的场景:

生产者是往队列里添加元素的线程,消费者是从队列里拿元素的线程
阻塞队列就是生产者存放元素的容器,而消费者也只从容器里拿元素。

iisheng wechat
微信扫码关注 Coder阿胜