首页 > 其他分享 >多线程面试笔记

多线程面试笔记

时间:2024-10-10 13:48:03浏览次数:13  
标签:多线程 synchronized Thread 笔记 面试 线程 执行 方法 CPU

1-多线程与并发基础

1.1-线程和进程的区别

什么是线程和进程?

进程:

  • 程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的 。

  • 当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。

  • 进程就可以视为程序的一个实例。大部分程序可以同时运行多个实例进程(例如记事本、画图、浏览器 等),也有的程序只

  • 能启动一个实例进程(例如网易云音乐、360 安全卫士等)。

  • 操作系统会以进程为单位,分配系统资源(CPU 时间片、内存等资源),进程是资源分配的最小单位

线程:

  • 线程是进程中的实体,一个进程可以拥有多个线程,一个线程必须有一个父进程。

  • 一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行 。

  • 线程,有时被称为轻量级进程(Lightweight Process,LWP),是操作系统调度(CPU 调度)执行的最小单位。

进程与线程的区别

线程具有许多传统进程所具有的特征,故又称为轻型进程(Light—WeightProcess)或进程元;而把传统的进程称为重型进程(Heavy—Weight Process),它相当于只有一个线程的任务。在引入了线程的操作系统中,通常一个进程都有若干个线程,至少包含一个线程。

  • 根本区别:进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位;

  • 资源开销:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小;

  • 包含关系:如果一个进程内有多个线程,则执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程;

  • 内存分配:同一进程的线程共享本进程的地址空间和资源,而进程之间的地址空间和资源是相互独立的;

  • 影响关系:一个进程崩溃后,在保护模式下不会对其他进程产生影响,但是一个线程崩溃整个进程都死掉。所以多进程要比多线程健壮;

  • 执行过程:每个独立的进程有程序运行的入口、顺序执行序列和程序出口。但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制,两者均可并发执行;

1.2-新启线程有哪几种方式?

创建线程有四种方式:

  • 继承 Thread 类;

  • 实现 Runnable 接口;

  • 实现 Callable 接口;

  • 使用 Executors 工具类创建线程池

继承 Thread 类

步骤

  • 定义一个 Thread 类的子类,重写 run 方法,将相关逻辑实现,run()方法就是线程要执行的业务逻辑方法

  • 创建自定义的线程子类对象

  • 调用子类实例的 star() 方法来启动线程

public class MyThread extends Thread {
@Override
        public void run() {
        System.out.println(Thread.currentThread().getName() + " run()方法正在执行...");
        }
}
public class TheadTest {
        public static void main(String[] args) {
                MyThread myThread = new MyThread();
                myThread.start();
                System.out.println(Thread.currentThread().getName() + " main()方法执行结束");
        }
}

       


运行结果

main main()方法执行结束
Thread‐0 run()方法正在执行...

实现 Runnable 接口

步骤

  • 定义 Runnable 接口实现类 MyRunnable,并重写 run() 方法

  • 创建 MyRunnable 实例 myRunnable,以 myRunnable 作为 target 创建 Thead 对象,该 Thread 对象才是真正的线程对象

  • 调用线程对象的 start() 方法

public class MyRunnable implements Runnable {
@Override
        public void run() {
                System.out.println(Thread.currentThread().getName() + " run()方法执行中...");
        }
}
public class RunnableTest {
        public static void main(String[] args) {
                MyRunnable myRunnable = new MyRunnable();
                Thread thread = new Thread(myRunnable);
                thread.start();
                System.out.println(Thread.currentThread().getName() + " main()方法执行完成");
        }
}

执行结果

main main()方法执行完成
Thread‐0 run()方法执行中...

实现 Callable 接口

步骤

  • 创建实现 Callable 接口的类 myCallable

  • 以 myCallable 为参数创建 FutureTask 对象

  • 将 FutureTask 作为参数创建 Thread 对象

  • 调用线程对象的 start() 方法

public class MyCallable implements Callable<Integer> {
@Override
        public Integer call() {
                System.out.println(Thread.currentThread().getName() + " call()方法执行中...");
                return 1;
        }
}
public class CallableTest {
        public static void main(String[] args) {
                FutureTask<Integer> futureTask = new FutureTask<Integer>(new
                MyCallable());
                Thread thread = new Thread(futureTask);
                thread.start();
                try {
                        Thread.sleep(1000);
                        System.out.println("返回结果 " + futureTask.get());
                } catch (InterruptedException e) {
                        e.printStackTrace();
                } catch (ExecutionException e) {
                        e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + " main()方法执行完成");
        }
}

执行结果

Thread‐0 call()方法执行中...

//返回结果 1

main main()方法执行完成

使用 Executors 工具类创建线程池

Executors 提供了一系列工厂方法用于创先线程池,返回的线程池都实现了ExecutorService接口。

主要有 newFixedThreadPool,newCachedThreadPool,newSingleThreadExecutor,newScheduledThreadPool,后续详细介绍这四种线程池

public class MyRunnable implements Runnable {
        @Override
        public void run() {
                System.out.println(Thread.currentThread().getName() + " run()方法执行中...");
        }
}
public class SingleThreadExecutorTest {
        public static void main(String[] args) {
                ExecutorService executorService = Executors.newSingleThreadExecutor();
                MyRunnable runnableTest = new MyRunnable();
        for (int i = 0; i < 5; i++) {
                        executorService.execute(runnableTest);
                }
                System.out.println("线程任务开始执行");
                executorService.shutdown();
        }
}

执行结果

线程任务开始执行

pool‐1‐thread‐1 is running...

pool‐1‐thread‐1 is running...

pool‐1‐thread‐1 is running...

pool‐1‐thread‐1 is running...

pool‐1‐thread‐1 is running...

说一下 runnablecallable 有什么区别?

相同点

  • 都是接口;

  • 都可以编写多线程程序;

  • 都采用Thread.start()启动线程;

主要区别

  • Runnable 接口 run 方法无返回值;Callable 接口 call 方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果;

  • Runnable 接口 run 方法只能抛出运行时异常,且无法捕获处理;Callable 接口,call 方法允许抛出异常,可以获取异常信息;

:Callalbe 接口支持返回执行结果,需要调用 FutureTask.get() 得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。

1.3-为什么要使用多线程呢?

线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销。

从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。

从计算机底层来说:

单核时代: 在单核时代多线程主要是为了提高 CPU 和 IO 设备的综合利用率。举个例子:当只有一个线程的时候会导致 CPU 计算时,IO 设备空闲;进行 IO 操作时,CPU 空闲。我们可以简单地说这两者的利用率目前都是 50%左右。但是当有两个线程的时候就不一样了,当一个线程执行CPU 计算时,另外一个线程可以进行 IO 操作,这样两个的利用率就可以在理想情况下达到 100%了。

多核时代:多核时代多线程主要是为了提高 CPU 利用率。举个例子:假如我们要计算一个复杂的任务,我们只用一个线程的话,CPU 只会一个 CPU 核心被利用到,而创建多个线程就可以让多个CPU 核心被利用到,这样就提高了 CPU 的利用率。

1.4-线程的状态和基本操作

  1. 新建(new):新创建了一个线程对象。

  2. 可运行(runnable):线程对象创建后,当调用线程对象的 start() 方法,该线程处于就绪状态,等待被线程调度选中,获取 cpu 的使用权。

  3. 运行(running):可运行状态(runnable)的线程获得了 cpu 时间片(timeslice),执行程序代码。注:就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中;

  4. 阻塞(block):处于运行状态中的线程由于某种原因,暂时放弃对 CPU 的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才有机会再次被 CPU 调用以进入到运行状态。

阻塞的情况分三种:

  • 等待阻塞:运行状态中的线程执行 wait() 方法,JVM会把该线程放入等待队列(waitting queue)中,使本线程进入到等待阻塞状态;

  • 同步阻塞:线程在获取 synchronized 同步锁失败(因为锁被其它线程所占用),,则 JVM 会把该线程放入锁池(lock pool)中,线程会进入同步阻塞状态;

  • 其他阻塞: 通过调用线程的 sleep() 或 join() 或发出了 I/O 请求时,线程会进入到阻塞状态。当 sleep() 状态超时、join() 等待线程终止或者超时、或者 I/O 处理完毕时,线程重新转入就绪状态。

  1. 死亡(dead):线程 run()、main() 方法执行结束,或者因异常退出了run() 方法,则该线程结束生命周期。死亡的线程不可再次复生。

1.5-常见的对比

1.5.1-说一下Runnable,Callable,Future,FutureTask?
1.5.2-notify() 和 notifyAll()

如果线程调用了对象的 wait() 方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。

notifyAll() 会唤醒所有的线程,notify() 只会唤醒一个线程。

notifyAll() 调用后,会将全部线程由等待池移到锁池,然后参与锁的竞争,竞争成功则继续执行,如果不成功则留在锁池等待锁被释放后再次参与竞争。而notify() 只会唤醒一个线程,具体唤醒哪一个线程由虚拟机控制。

1.5.3-sleep() 和 wait()

两者都可以暂停线程的执行

  • 类的不同:sleep() 是 Thread 线程类的静态方法,wait() 是 Object类的方法。

  • 是否释放锁:sleep() 不释放锁;wait() 释放锁。

  • 用途不同:Wait 通常被用于线程间交互/通信,sleep 通常被用于暂停执行。

  • 用法不同:wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒。或者可以使用 wait(long timeout) 超时后线程会自动苏醒

1.5.4-start() 和 run()

new 一个 Thread,线程进入了新建状态。调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。

而直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

总结: 调用 start 方法方可启动线程并使线程进入就绪状态,而 run 方法只是 thread 的一个普通方法调用,还是在主线程里执行。

具体区别:

每个线程都是通过某个特定 Thread 对象所对应的方法 run() 来完成其操作的,run() 方法称为线程体。通过调用Thread 类的 start() 方法来启动一个线程。start() 方法用于启动线程,run() 方法用于执行线程的运行时代码。run() 可以重复调用,而 start() 只能调用一次。start()方法来启动一个线程,真正实现了多线程运行。

调用 start() 方法无需等待 run 方法体代码执行完毕,可以直接继续执行其他的代码; 此时线程是处于就绪状态,并没有运行。 然后通过此 Thread 类调用方法 run() 来完成其运行状态,run() 方法运行结束, 此线程终止。然后 CPU 再调度其它线程。

run() 方法是在本线程里的,只是线程里的一个函数,而不是多线程的。 如果直接调用 run(),其实就相当于是调用了一个普通函数而已,直接待用 run() 方法必须等待 run() 方法执行完毕才能执行下面的代码,所以执行路径还是只有一条,根本就没有线程的特征,所以在多线程执行时要使用 start() 方法而不是 run() 方法。

1.5.5-sleep() 和 yield()

Thread 类的 sleep() 和 yield()方法将在当前正在执行的线程上运行。所以在其他处于等待状态的线程上调用这些方法是没有意义的。这就是为什么这些方法是静态的。它们可以在当前正在执行的线程中工作,并避免程序员错误的认为可以在其他非运行线程调用这些方法。

区别:

  • sleep()方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会;yield()方法只会给相同优先级或更高优先级的线程以运行的机会;

  • 线程执行 sleep() 方法后转入阻塞(blocked)状态,而执行 yield() 方法后转入就绪(ready)状态;

  • sleep() 方法声明抛出 InterruptedException,而 yield() 方法没有声明任何异常;sleep() 方法比 yield() 方法(跟操作系统 CPU 调度相关)具有更好的可移植性,通常不建议使用 yield() 方法来控制并发线程的执行。

1.6-进程间通信

同一台计算机的进程通信称为 IPC(Inter-process communicaƟ on),不同计算机之间的进程通信被称为 R(mote)PC,需要通过网络,并遵守共同的协议,比如大家熟悉的 Dubbo 就是一个 RPC 框架, 而 Http 协议也经常用在 RPC 上, 比如 SpringCloud 微服务。

大厂常见的面试题就是,进程间通信有几种方式?

  • 管道, 分为匿名管道(pipe)及命名管道(named pipe):匿名管道可用于具有亲缘关系的父子进程间的通信,命名管道除了具有管道所具有的功能外, 它还允许无亲缘关系进程间的通信。

  • 信号(signal):信号是在软件层次上对中断机制的一种模拟,它是比较复杂的通信方式, 用于通知进程有某事件发生, 一个进程收到一个信号与处理器 收到一个中断请求效果上可以说是一致的。

  • 消息队列(message queue):消息队列是消息的链接表,它克服了上两 种通信方式中信号量有限的缺点, 具有写权限得进程可以按照一定得规则向消息 队列中添加新信息;对消息队列有读权限得进程则可以从消息队列中读取信息。

  • 共享内存(shared memory):可以说这是最有用的进程间通信方式。它 使得多个进程可以访问同一块内存空间, 不同进程可以及时看到对方进程中对共 享内存中数据得更新。这种方式需要依靠某种同步操作, 如互斥锁和信号量等。

  • 信号量(semaphore):主要作为进程之间及同一种进程的不同线程之间 得同步和互斥手段。

  • 套接字(socket):这是一种更为一般得进程间通信机制, 它可用于网络 中不同机器之间的进程间通信,应用非常广泛。同一机器中的进程还可以使用 Unix domain socket(比如同一机器中MySQL中的控制台 mysql shell 和 MySQL 服 务程序的连接),这种方式不需要经过网络协议栈, 不需要打包拆包、计算校验 和、维护序号和应答等,比纯粹基于网络的进程间通信肯定效率更高。

1.7-什么是上下文切换?如何减少线程上下文开销?

上下文切换是指 CPU (中央处理单元)从一个进程或线程到另一个进程或线程的切换。

进程是程序的一个执行实例。在 Linux 中,线程是轻量级进程,可以并行运行,并与父进程(即创建线程的进程)共享一个地址空间和其他资源。上下文是 CPU 寄存器和程序计数器在任何时间点的内容。寄存器是 CPU 内部的一小部分非常快的内存(相对于 CPU 外部较慢的RAM主内存),它通过提供对常用值的快速访问来加快计算机程序的执行。程序计数器是一种专门的寄存器,它指示 CPU 在其指令序列中的位置,并保存着正在执行的指令的地址或下一条要执行的指令的地址,这取决于具体的系统。

上下文切换可以更详细地描述为内核(即操作系统的核心)对 CPU 上的进程(包括线程)执行以下活动:

  • 暂停一个进程的处理,并将该进程的 CPU 状态(即上下文)存储在内存中的某个地方

  • 从内存中获取下一个进程的上下文,并在 CPU 的寄存器中恢复它

  • 返回到程序计数器指示的位置(即返回到进程被中断的代码行)以恢复进程。

1.8-守护线程和用户线程的区别?

用户 (User) 线程:运行在前台,执行具体的任务,如程序的主线程、连接网络的子线程等都是用户线程

守护 (Daemon) 线程:运行在后台,为其他前台线程服务。也可以说守护线程是 JVM 中非守护线程的 “佣人”。一旦所有用户线程都结束运行,守护线程会随 JVM 一起结束工作

main 函数所在的线程就是一个用户线程啊,main 函数启动的同时在 JVM 内部同时还启动了好多守护线程,比如垃圾回收线程。比较明显的区别之一是用户线程结束,JVM 退出,不管这个时候有没有守护线程运行。而守护线程不会影响 JVM 的退出。

注意事项:

  • setDaemon(true) 必须在 start() 方法前执行,否则会抛出 IllegalThreadStateException 异常;

  • 在守护线程中产生的新线程也是守护线程;

  • 不是所有的任务都可以分配给守护线程来执行,比如读写操作或者计算逻辑;

  • 守护 (Daemon) 线程中不能依靠 finally 块的内容来确保执行关闭或清理资源的逻辑。因为我们上面也说过了一旦所有用户线程都结束运行,守护线程会随 JVM 一起结束工作,所以守护 (Daemon) 线程中的 finally 语句块可能无法被执行;

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

在 java 中有以下3种方法可以终止正在运行的线程:

  • 使用退出标志,使线程正常退出,也就是当 run 方法完成后线程终止。

  • 使用 stop 方法强行终止,但是不推荐这个方法,因为 stop 和 suspend 及 resume 一样都是过期作废的方法。

  • 使用 interrupt 方法中断线程。

1.10-为什么线程通信的方法 wait(),notify() 和 notifyAll() 被定义在 Object 里?

Java 中,任何对象都可以作为锁,并且 wait(),notify() 等方法用于等待对象的锁或者唤醒线程,在 Java 的线程中并没有可供任何对象使用的锁,所以任意对象调用方法一定定义在 Object 类中。

wait(), notify() 和 notifyAll() 这些方法在同步代码块中调用.

有的人会说,既然是线程放弃对象锁,那也可以把 wait() 定义在 Thread 类里面啊,新定义的线程继承于 Thread 类,也不需要重新定义 wait() 方法的实现。然而,这样做有一个非常大的问题,一个线程完全可以持有很多锁,你一个线程放弃锁的时候,到底要放弃哪个锁?当然了,这种设计并不是不能实现,只是管理起来更加复杂。

综上所述,wait()、notify() 和 notifyAll() 方法要定义在Object类中。

线程通信的方法 wait(), notify(), 和 notifyAll() 被定义在 Object 类中,而不是在 Thread 类中,这是因为它们是与对象的监视器锁相关联的,而不是与线程本身相关联的。以下是详细解释:

  1. 监视器锁的概念

每个对象都有一个关联的监视器锁(monitor)。当线程进入同步方法或同步块时,它必须获得该对象的监视器锁。如果其他线程已经持有这个监视器锁,则当前线程必须等待,直到监视器锁被释放。

  1. wait(), notify(), 和 notifyAll() 的工作原理

  • wait():使当前线程等待,直到另一个线程调用该对象的 notify()notifyAll() 方法。调用 wait() 时,线程必须持有对象的监视器锁。线程会释放该锁,然后进入等待状态,直到被唤醒。

  • notify():唤醒一个等待这个对象监视器锁的线程。如果有多个线程在等待这个锁,则选择其中一个线程进行唤醒。

  • notifyAll():唤醒所有等待这个对象监视器锁的线程。

  1. 为什么在 Object 类中定义

3.1 监视器锁与对象关联

  • 监视器锁是与对象(而非线程)关联的。任何对象都可以有同步的方法或同步块,这些同步块是在对象的监视器锁基础上运作的。

  • 因为 wait(), notify(), 和 notifyAll() 是与对象的监视器锁相关的操作,而不是与线程的内部状态相关的操作,所以它们被定义在 Object 类中。

3.2 适用于所有对象

  • Object 类是所有 Java 类的超类,因此任何对象都继承了 Object 的方法。

  • 将线程通信方法定义在 Object 类中,使得所有对象都具备线程等待和通知功能,保证了每个对象都可以作为同步的锁对象。

示例代码

下面是一个简单的示例说明 wait(), notify(), 和 notifyAll() 的使用:

public class ThreadCommunication {
    private static final Object lock = new Object();

    public static void main(String[] args) {
        Thread thread1 = new Thread(new WaitTask(), "Thread-1");
        Thread thread2 = new Thread(new NotifyTask(), "Thread-2");

        thread1.start();
        thread2.start();
    }

    static class WaitTask implements Runnable {
        @Override
        public void run() {
            synchronized (lock) {
                try {
                    System.out.println(Thread.currentThread().getName() + " is waiting.");
                    lock.wait(); // Thread-1 will wait here
                    System.out.println(Thread.currentThread().getName() + " has been notified.");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    static class NotifyTask implements Runnable {
        @Override
        public void run() {
            synchronized (lock) {
                try {
                    System.out.println(Thread.currentThread().getName() + " is sleeping for a while.");
                    Thread.sleep(2000); // Simulate some work
                    System.out.println(Thread.currentThread().getName() + " is notifying.");
                    lock.notify(); // Thread-2 will notify after some time
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

在这个示例中,我们定义了一个 lock 对象,并在 Thread-1 中调用 lock.wait(),在 Thread-2 中调用 lock.notify()wait() 使得 Thread-1lock 对象上等待,直到 Thread-2 调用 notify() 唤醒它。

结论

wait(), notify(), 和 notifyAll() 方法被定义在 Object 类中,因为它们是基于对象监视器锁的操作,而不是线程本身的操作。通过将这些方法定义在 Object 类中,所有对象都能够参与线程间的通信和同步,这符合面向对象编程的设计原则。

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

Thread 类的 sleep() 和 yield() 方法将在当前正在执行的线程上运行。所以在其他处于等待状态的线程上调用这些方法是没有意义的。这就是为什么这些方法是静态的。它们可以在当前正在执行的线程中工作,并避免程序员错误的认为可以在其他非运行线程调用这些方法。

1.12-Java 中的 interrupted 和 isInterrupted 的区别?

  • interrupt:用于中断线程。调用该方法的线程的状态为将被置为”中断”状态。注意:线程中断仅仅是置线程的中断状态位,不会停止线程。需要用户自己去监视线程的状态为并做处理。支持线程中断的方法(也就是线程中断后会抛出 interruptedException 的方法)就是在监视线程的中断状态,一旦线程的中断状态被置为“中断状态”,就会抛出中断异常;

  • interrupted:是静态方法,查看当前中断信号是 true 还是 false 并且清除中断信号。如果一个线程被中断了,第一次调用 interrupted 则返回 true,第二次和后面的就返回 false 了;

  • isInterrupted:查看当前中断信号是 true 还是 false;

1.13-什么是内存屏障?

1.14-什么是可重排序?

1.15-有三个线程t1,t2,t3,如何保证线程顺序执行?

在多线程中有多种方法让线程按特定顺序执行,你可以用线程类的 join() 方法在一个线程中启动另一个线程,另外一个线程完成该线程继续执行。为了确保三个线程的顺序你应该先启动最后一个(T3调用T2,T2调用T1),这样T1就会先完成而T3最后完成。

实际上先启动三个线程中哪一个都行, 因为在每个线程的 run 方法中用 join 方法限定了三个线程的执行顺序。

public class JoinTest2 {
        // 1.现在有T1、T2、T3三个线程,你怎样保证T2在T1执行完后执行,T3在T2执行完后执行
        public static void main(String[] args) {
                final Thread t1 = new Thread(new Runnable() {
                @Override
                        public void run() {
                                System.out.println("t1");
                        }
                });
                final Thread t2 = new Thread(new Runnable() {
                @Override
                        public void run() {
                                try {
                                // 引用t1线程,等待t1线程执行完
                                        t1.join();
                                } catch (InterruptedException e) {
                                        e.printStackTrace();
                                }
                        System.out.println("t2");
                        }
                });
                Thread t3 = new Thread(new Runnable() {
                        @Override
                        public void run() {
                                try {
                                        // 引用t2线程,等待t2线程执行完
                                        t2.join();
                                } catch (InterruptedException e) {
                                        e.printStackTrace();
                                }
                                System.out.println("t3");
                        }
                });
                t3.start();//这里三个线程的启动顺序可以任意,大家可以试下!
                t2.start();
                t1.start();
        }
}

1.16- 线程阻塞的三种情况

  • 等待阻塞:运行状态中的线程执行 wait() 方法,JVM 会把该线程放入等待队列(waitting queue)中,使本线程进入到等待阻塞状态;

  • 同步阻塞:线程在获取 synchronized 同步锁失败(因为锁被其它线程所占用),,则JVM会把该线程放入锁池(lock pool)中,线程会进入同步阻塞状态;

  • 其他阻塞: 通过调用线程的 sleep()或 join()或发出了 I/O 请求时,线程会进入到阻塞状态。当 sleep() 状态超时、join() 等待线程终止或者超时、或者 I/O 处理完毕时,线程重新转入就绪状态。

1.17- 线程死亡的三种方式

1.18-并发编程的优缺点

为什么要使用并发编程(并发编程的优点)

  • 充分利用多核 CPU 的计算能力:通过并发编程的形式可以将多核 CPU 的计算能力发挥到极致,性能得到提升

  • 方便进行业务拆分,提升系统并发能力和性能:在特殊的业务场景下,先天的就适合于并发编程。现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。面对复杂业务模型,并行程序会比串行程序更适应业务需求,而并发编程更能吻合这种业务拆分 。

并发编程有什么缺点

  • 并发编程的目的就是为了能提高程序的执行效率,提高程序运行速度,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、上下文切换、线程安全死锁等问题。

1.19-并发和并行

  • 并发:多个任务在同一个 CPU 核上,按细分的时间片轮流(交替)执行,从逻辑上来看那些任务是同时执行。

  • 并行:单位时间内,多个处理器或多核处理器同时处理多个任务,是真正意义上的“同时进行”。

  • 串行:有n个任务,由一个线程按顺序执行。由于任务、方法都在一个线程执行所以不存在线程不安全情况,也就不存在临界区的问题。

做一个形象的比喻:

并发 = 两个队列和一台咖啡机。

并行 = 两个队列和两台咖啡机。

串行 = 一个队列和一台咖啡机。

1.20-并发三大特性

1.21-协程

协程

协程,英文 Coroutines, 是一种基于线程之上,但又比线程更加轻量级的存在,协程不是被操作系统内核所管理,而完全是由程序所控制(也就是在用户态执行),具有对内核来说不可见的特性。这样带来的好处就是性能得到了很大的提升,不会像线程切换那样消耗资源。

协程的特点在于是一个线程执行,那和多线程比,协程有何优势?

  • 线程的切换由操作系统调度,协程由用户自己进行调度,因此减少了上下文切换,提高了效率;

  • 线程的默认 stack 大小是1M,而协程更轻量,接近1k。因此可以在相同的内存中开启更多的协程;

  • 不需要多线程的锁机制:因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多;

1.22-Java 线程的调度机制

线程调度是指系统为线程分配处理器使用权的过程,主要调度方式分两种,分别是协同式线程调度和抢占式线程调度

协同式线程调度

线程执行时间由线程本身来控制,线程把自己的工作执行完之后,要主动通知系统切换到另外一个线程上。最大好处是实现简单,且切换操作对线程自己是可知的,没啥线程同步问题。坏处是线程执行时间不可控制,如果一个线程有问题,可能一直阻塞在那里。

抢占式线程调度

每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定(Java 中,Thread.yield() 可以让出执行时间,但无法获取执行时间)。线程执行时间系统可控,也不会有一个线程导致整个进程阻塞。

1-23-Thread 常用方法

sleep方法

  • 调用 sleep 会让当前线程从进入TIMED_WAITING状态,不会释放对象锁

  • 其它线程可以使用 interrupt 方法打断正在睡眠的线程,这时 sleep 方法会抛出InterruptedException,并且会清除中断标志

  • 睡眠结束后的线程未必会立刻得到执行

  • sleep 当传入参数为0时,和 yield 相同

yield 方法

  • yield 会释放 CPU 资源,让当前线程从 Running 进入 Runnable 状态,让优先级更高(至少是相同)的线程获得执行机会,不会释放对象锁;

  • 假设当前进程只有 main 线程,当调用 yield 之后,main 线程会继续运行,因为没有比它优先级更高的线程;

  • 具体的实现依赖于操作系统的任务调度器 join() 方法等待调用 join() 方法的线程结束之后,程序再继续执行,一般用于等待异步线程执行完结果之后才能继续运行的场景。

join() 方法

等待调用join() 方法的线程结束之后,程序再继续执行,一般用于等待异步线程执行完结果之后才能继续运行的场景。

stop() 方法

stop() 方法已经被 jdk 废弃,原因就是 stop() 方法太过于暴力,强行把执行到一半的线程终止;

2-synchronized

2.1-synchronized 的作用

在 Java 中,synchronized 关键字是用来控制线程同步的,就是在多线程的环境下,控制 synchronized 代码段不被多个线程同时执行。synchronized 可以修饰类、方法、变量。

另外,在 Java 早期版本中,synchronized 属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。JDK1.6对锁的实现引入了大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。

2.2-synchronized 的底层原理

synchronized 是Java 中的一个关键字,在使用的过程中并没有看到显示的加锁和解锁过程。因此有必要通过 javap 命令,查看相应的字节码文件。

synchronized 同步语句块的情况:

public class SynchronizedDemo {
        public void method() {
                synchronized (this) {
                        System.out.println("synchronized 代码块");
                }
        }
}

通过JDK 反汇编指令 javap -c -v SynchronizedDemo

可以看出在执行同步代码块之前之后都有一个 monitor 字样,其中前面的是 monitorenter,后面的是离开monitorexit,不难想象一个线程也执行同步代码块,首先要获取锁,而获取锁的过程就是 monitorenter ,在执行完代码块之后,要释放锁,释放锁就是执行 monitorexit 指令。

为什么会有两个 monitorexit 呢?

这个主要是防止在同步代码块中线程因异常退出,而锁没有得到释放,这必然会造成死锁(等待的线程永远获取不到锁)。因此最后一个 monitorexit 是保证在异常情况下,锁也可以得到释放,避免死锁。仅有 ACC_SYNCHRONIZED这么一个标志,该标记表明线程进入该方法时,需要 monitorenter,退出该方法时需要 monitorexit。

synchronized 可重入的原理

重入锁是指一个线程获取到该锁之后,该线程可以继续获得该锁。底层原理维护一个计数器,当线程获取该锁时,计数器加一,再次获得该锁时继续加一,释放锁时,计数器减一,当计数器值为0时,表明该锁未被任何线程所持有,其它线程可以竞争获取锁。

2.3-说一下怎样使用 synchronized,在项目里用了吗?

synchronized 关键字最主要的三种使用方式:

修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁;

修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员(static 表明这是该类的一个静态资源,不管new了多少个对象,只有一份)。所以如果一个线程A调用一个实例对象的非静态 synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的**锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁

修饰代码块: 指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁;

总结: synchronized 关键字加到 static 静态方法和 synchronized(class)代码块上都是是给 Class 类上锁。synchronized 关键字加到实例方法上是给对象实例上锁。尽量不要使用 synchronized(String a) 因为JVM中,字符串常量池具有缓存功能!

下面我以一个常见的面试题为例讲解一下 synchronized 关键字的具体使用。面试中面试官经常会说:“单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理呗!”

双重校验锁实现对象单例线程安全

public class Singleton {
        private volatile static Singleton uniqueInstance;
        private Singleton() {
        }
        public static Singleton getUniqueInstance() {
                //先判断对象是否已经实例过,没有实例化过才进入加锁代码
                if (uniqueInstance == null) {
                        //类对象加锁
                        synchronized (Singleton.class) {
                                if (uniqueInstance == null) {
                                        uniqueInstance = new Singleton();
                                }
                        }
                }
                return uniqueInstance;
    }
}

另外,需要注意 uniqueInstance 采用 volatile 关键字修饰也是很有必要。uniqueInstance 采用 volatile 关键字修饰也是很有必要的, uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:

  • 为 uniqueInstance 分配内存空间

  • 初始化 uniqueInstance

  • 将 uniqueInstance 指向分配的内存地址

但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回uniqueInstance,但此时 uniqueInstance 还未被初始化。

使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。

2.4-多线程中 synchronized 锁升级的原理是什么?

synchronized 锁升级原理:在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候 threadid 为空,jvm 让其持有偏向锁,并将 threadid 设置为其线程 id,再次进入的时候会先判断 threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了 synchronized 锁的升级。

锁的升级的目的:锁升级是为了减低了锁带来的性能消耗。在 Java 6 之后优化synchronized 的实现方式,使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,从而减低了锁带来的性能消耗。

2.5-synchronized 和 lock() 的区别?

  • 首先 synchronized 是 Java 内置关键字,在 JVM 层面,Lock 是个 Java 类;

  • synchronized 可以给类、方法、代码块加锁;而 lock 只能给代码块加锁;

  • synchronized 不需要手动获取锁和释放锁,使用简单,发生异常会自动释放锁,不会造成死锁;而 lock 需要自己加锁和释放锁,如果使用不当没有 unLock() 去释放锁就会造成死锁;

  • 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到;

2.6-synchronized和ReetrantLock的区别

3-volatile

3.1-volatile 关键字的作用

  • 对于可见性,Java 提供了 volatile 关键字来保证可见性和禁止指令重排。

  • volatile 提供 happens-before 的保证,确保一个线程的修改能对其他线程是可见的。当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。

  • 从实践角度而言,volatile 的一个重要作用就是和 CAS 结合,保证了原子性,详细的可以参见 java.util.concurrent.atomic 包下的类,比如AtomicInteger。

  • volatile 常用于多线程环境下的单次操作(单次读或者单次写)。

3.2-volatile 变量与 atomic 有什么不同?

volatile 变量可以确保先行关系,即写操作会发生在后续的读操作之前, 但它并不能保证原子性。例如用 volatile 修饰 count 变量,那么 count++ 操作就不是原子性的。

而 AtomicInteger 类提供的 atomic 方法可以让这种操作具有原子性如getAndIncrement()方法会原子性的进行增量操作把当前值加一,其它数据类型和引用变量也可以进行相似操作。

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

关键字 volatile 的主要作用是使变量在多个线程间可见,但无法保证原子性,对于多个线程访问同一个实例变量需要加锁进行同步。

虽然 volatile 只能保证可见性不能保证原子性,但用 volatile 修饰 long 和 double 可以保证其操作原子性。所以从Oracle Java Spec 里面可以看到:

  • 对于64位的 long 和 double,如果没有被 volatile 修饰,那么对其操作可以不是原子的。在操作的时候,可以分成两步,每次对32位操作;

  • 如果使用 volatile 修饰 long 和 double,那么其读写都是原子操作;

  • 对于64位的引用地址的读写,都是原子操作;

  • 在实现 JVM 时,可以自由选择是否把读写 long 和 double 作为原子操作推荐JVM实现为原子操作;

3.4-volatile 和 synchronized 的区别?

synchronized 表示只有一个线程可以获取作用对象的锁,执行代码,阻塞其他线程。

volatile 表示变量在 CPU 的寄存器中是不确定的,必须从主存中读取。保证多线程环境下变量的可见性;禁止指令重排序。

区别

  • volatile 是变量修饰符;synchronized 可以修饰类、方法、变量。

  • volatile 仅能实现变量的修改可见性,不能保证原子性;而synchronized 则可以保证变量的修改可见性和原子性。

  • volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。

  • volatile标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化。

  • volatile关键字是线程同步的轻量级实现,所以 volatile 性能肯定比 synchronized 关键字要好。但是 volatile 关键字只能用于变量而 synchronized 关键字可以修饰方法以及代码块。synchronized 关键字在 JavaSE1.6 之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁以及其它各种优化之后执行效率有了显著提升,实际开发中使用 synchronized 关键字的场景还是更多一些

3.5-volatile 可见性的实现原理?

将 valatile 写入代码进行编译成字节码是,可以看到代码行中多了一条指令,lock addl 这个指令相当于内存屏障的作用,保证内存屏障后的代码不能在内存屏障前执行。

4-CAS

4.1-什么是自旋?

很多 synchronized 里面的代码只是一些很简单的代码,执行时间非常快,此时等待的线程都加锁可能是一种不太值得的操作,因为线程阻塞涉及到用户态和内核态切换的问题。既然 synchronized 里面的代码执行得非常快,不妨让等待锁的线程不要被阻塞,而是在 synchronized 的边界做忙循环,这就是自旋。如果做了多次循环发现还没有获得锁,再阻塞,这样可能是一种更好的策略。

4.2-CAS 会产生什么问题?

1、ABA 问题:

比如说一个线程 one 从内存位置 V 中取出 A,这时候另一个线程 two 也从内存中取出 A,并且 two 进行了一些操作变成了 B,然后 two 又将 V 位置的数据变成 A,这时候线程 one 进行 CAS 操作发现内存中仍然是 A,然后 one 操作成功。尽管线程 one 的 CAS 操作成功,但可能存在潜藏的问题。从 Java1.5 开始 JDK 的 atomic包里提供了一个类 AtomicStampedReference 来解决 ABA问题。

2、循环时间长开销大:

对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized。

3、只能保证一个共享变量的原子操作:

当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁。

5-死锁

5.1-什么是死锁?

当线程 A 持有独占锁a,并尝试去获取独占锁 b 的同时,线程 B 持有独占锁b,并尝试获取独占锁 a 的情况下,就会发生 AB 两个线程由于互相持有对方需要的锁,而发生的阻塞现象,我们称为死锁。

5.2-产生死锁的条件是什么?

产生死锁的必要条件:

1、互斥条件:所谓互斥就是进程在某一时间内独占资源。

2、请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。

3、不剥夺条件:进程已获得资源,在末使用完之前,不能强行剥夺。

4、循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之 一不满足,就不会发生死锁。理解了死锁的原因,尤其是产生死锁的四个必要条件,就可以最大可能地避免、预防和解除死锁。

防止死锁可以采用以下的方法:

  • 尽量使用 tryLock(long timeout, TimeUnit unit)的方法(ReentrantLock、ReentrantReadWriteLock),设置超时时间,超时可以退出防止死锁。

  • 尽量使用 Java. util. concurrent 并发类代替自己手写锁。

  • 尽量降低锁的使用粒度,尽量不要几个功能用同一把锁。

  • 尽量减少同步的代码块。

5.3-怎样预防死锁?

5.4-死锁和活锁的区别?死锁和饥饿锁的区别

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

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

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

饥饿:一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行的状态。

Java 中导致饥饿的原因:

1、高优先级线程吞噬所有的低优先级线程的 CPU 时间。

2、线程被永久堵塞在一个等待进入同步块的状态,因为其他线程总是能在它之前持续地对该同步块进行访问。

3、线程在等待一个本身也处于永久等待完成的对象(比如调用这个对象的 wait方法),因为其他线程总是被持续地获得唤醒。

6-AQS

6.1-AQS原理分析

  1. AQS 是一个锁框架,它定义了锁的实现机制,并开放出扩展的地方,让子类去实现,比如我们在lock 的时候,AQS 开放出 state 字段,让子类可以根据 state 字段来决定是否能够获得锁,对于获取不到锁的线程 AQS 会自动进行管理,无需子类锁关心,这就是 lock 时锁的内部机制,封装的很好,又暴露出子类锁需要扩展的地方;

  2. AQS 底层是由同步队列 + 条件队列联手组成,同步队列管理着获取不到锁的线程的排队和释放,条件队列是在一定场景下,对同步队列的补充,比如获得锁的线程从空队列中拿数据,肯定是拿不到数据的,这时候条件队列就会管理该线程,使该线程阻塞;

  3. AQS 围绕两个队列,提供了四大场景,分别是:获得锁、释放锁、条件队列的阻塞,条件队列的唤醒,分别对应着 AQS 架构图中的四种颜色的线的走向。

6.2-AQS 使用了哪些设计模式?

AQS 同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样(模板方法模式很

经典的一个应用):

  1. 使用者继承 AbstractQueuedSynchronizer 并重写指定的方法。(这些重写方法很简单,无非是对 于共享资源 state 的获取和释放)

  2. 将 AQS 组合在自定义同步组件的实现中,并调用其模板方法,而这些模板方法会调用使用者重写的 方法。

这和我们以往通过实现接口的方式有很大区别,这是模板方法模式很经典的一个运用。

AQS 使用了模板方法模式,自定义同步器时需要重写下面几个 AQS 提供的模板方法:

isHeldExclusively()//该线程是否正在独占资源。只有用到condition才需要去实现它。
tryAcquire(int)//独占方式。尝试获取资源,成功则返回true,失败则返回false。
tryRelease(int)//独占方式。尝试释放资源,成功则返回true,失败则返回false。
tryAcquireShared(int)//共享方式。尝试获取资源。负数表示失败; 0 表示成功,但没有剩余可用资源;
正数表示成功,且有剩余资源。
tryReleaseShared(int)//共享方式。尝试释放资源,成功则返回true,失败则返回false。

6.3-了解 AQS 中同步队列的数据结构吗?

  • 当前线程获取同步状态失败,同步器将当前线程机等待状态等信息构造成一个 Node 节点加入队列,放在队尾,同步器重新设置尾节点;

  • 加入队列后,会阻塞当前线程;

  • 同步状态被释放并且同步器重新设置首节点,同步器唤醒等待队列中第一个节点,让其再次获取同 步状态;

6.4-了解 AQS 对资源的共享方式吗?

AQS 定义两种资源共享方式

  • Exclusive (独占):只有一个线程能执行,如 ReentrantLock。又可分为公平锁和非公平锁:

    • 公平锁:按照线程在队列中的排队顺序,先到者先拿到锁

    • 非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的

  • Share (共享):多个线程可同时执行,如Semaphore/CountDownLatch。Semaphore、CountDownLatCh、 CyclicBarrier、ReadWriteLock 我们都会在后面讲到。

ReentrantReadWriteLock 可以看成是组合式,因为 ReentrantReadWriteLock 也就是读写锁允许多个线程同时对某一资源进行读。

不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在顶层实现好了。

6.5-AQS 组件了解吗?

  • Semaphore( 信号量 )- 允许多个线程同时访问: synchronized 和 ReentrantLock 都是一次只允许一个线程访问某个资源,Semaphore(信号量)可以指定多个线程同时访问某个资源;

  • CountDownLatch (倒计时器): CountDownLatch是一个同步工具类,用来协调多个线程之间的同步。这个工具通常用来控制线程等待,它可以让某一个线程等待直到倒计时结束,再开始执行;

  • CyclicBarrier( 循环栅栏 ) : CyclicBarrier 和 CountDownLatch 非常类似,它也可以实现线程间的技术等待,但是它的功能比 CountDownLatch 更加复杂和强大。主要应用场景和CountDownLatch 类似。CyclicBarrier 的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活。CyclicBarrier默认的构造方法是CyclicBarrier(int parties),其参数表示屏障拦截的线程数量,每个线程调用await方法告诉CyclicBarrier 我已经到达了屏障,然后当前线程被阻塞;

7-ThreadLocal

7.1-ThreadLocal 是什么?有哪些使用场景?

ThreadLocal 是一个本地线程副本变量工具类,在每个线程中都创建了一个ThreadLocalMap 对象,简单说 ThreadLocal 就是一种以空间换时间的做法,每个线程可以访问自己内部 ThreadLocalMap 对象内的 value。通过这种方式,避免资源在多线程间共享。

原理:线程局部变量是局限于线程内部的变量,属于线程自身所有,不在多个线程间共享。Java提供ThreadLocal类来支持线程局部变量,是一种实现线程安全的方式。但是在管理环境下(如 web 服务器)使用线程局部变量的时候要特别小心,在这种情况下,工作线程的生命周期比任何应用变量的生命周期都要长。任何线程局部变量一旦在工作完成后没有释放,Java 应用就存在内存泄露的风险。

经典的使用场景是为每个线程分配一个 JDBC 连接 Connection。这样就可以保证每个线程的都在各自的 Connection 上进行数据库的操作,不会出现 A 线程关了 B线程正在使用的 Connection; 还有 Session 管理 等问题。

ThreadLocal 使用例子:

public class TestThreadLocal {
        private static final ThreadLocal<Integer> THREAD_LOCAL_NUM = new ThreadLocal<Integer>() {
        @Override
        protected Integer initialValue() {
                return 0;
        }
};

        public static void main(String[] args) {
                for (int i = 0; i <3; i++) {//启动三个线程
                        Thread t = new Thread() {
                        @Override
                        public void run() {
                                add10ByThreadLocal();
                        }
                };
                t.start();
        }
}

/**
线程本地存储变量加 5
*/
        private static void add10ByThreadLocal() {
                for (int i = 0; i <5; i++) {
                        Integer n = THREAD_LOCAL_NUM.get();
                        n += 1;
                        THREAD_LOCAL_NUM.set(n);
                        System.out.println(Thread.currentThread().getName() + " : ThreadLocal num=" + n);
                }
        }
}

打印结果:启动了 3 个线程,每个线程最后都打印到 “ThreadLocal

num=5”,而不是 num 一直在累加直到值等于 15

Thread‐0 : ThreadLocal num=1

Thread‐1 : ThreadLocal num=1

Thread‐0 : ThreadLocal num=2

Thread‐0 : ThreadLocal num=3

Thread‐1 : ThreadLocal num=2

Thread‐2 : ThreadLocal num=1

Thread‐0 : ThreadLocal num=4

Thread‐2 : ThreadLocal num=2

Thread‐1 : ThreadLocal num=3

Thread‐1 : ThreadLocal num=4

Thread‐2 : ThreadLocal num=3

Thread‐0 : ThreadLocal num=5

Thread‐2 : ThreadLocal num=4

Thread‐2 : ThreadLocal num=5

Thread‐1 : ThreadLocal num=5

7.2-ThreadLoca 实现原理

是基于线程局部变量实现的,线程局部变量是线程私有变量,不允许在线程间进行共享,而且ThreadLocalMap支持线程局部变量,这也保障了线程安全。

7.3-ThreadLocal 造成内存泄露的主要原因?(需要完善)

因为 ThreadLocalMap 里的键值对,key 是软引用,value 是强引用,当 key 没有外部所引用时,会在下一次gc是进行回收,导致 key 为 null 的情况,长此以往,会致使内存泄漏。

7.4-ThreadLocal 造成内存泄露的解决方案?

  • 在不再需要 ThreadLocal 时,调用 remove() 方法,以确保删除该线程局部变量的值。

  • 使用 Try-with-resources: 在使用 ThreadLocal 的代码块中使用 try-with-resources 语句,以确保在使用完后正确清理。

  • 考虑使用弱引用: 如果适用,可以考虑使用弱引用或其他更合适的缓存机制。

8-线程池

8.1-什么是线程池,有哪几种创建方式?

池化技术相比大家已经屡见不鲜了,线程池、数据库连接池、Http 连接池等等都是对这个思想的应用。池化技术的思想主要是为了减少每次获取资源的消耗,提高对资源的利用率。

在面向对象编程中,创建和销毁对象是很费时间的,因为创建一个对象要获取内存资源或者其它更多资源。在 Java 中更是如此,虚拟机将试图跟踪每一个对象,以便能够在对象销毁后进行垃圾回收。所以提高服务程序效率的一个手段就是尽可能减少创建和销毁对象的次数,特别是一些很耗资源的对象创建和销毁,这就是”池化资源”技术产生的原因。

线程池顾名思义就是事先创建若干个可执行的线程放入一个池(容器)中,需要的时候从池中获取线程不用自行创建,使用完毕不需要销毁线程而是放回池中,从而减少创建和销毁线程对象的开销。Java 5+中的 Executor 接口定义一个执行线程的工具。它的子类型即线程池接口是 ExecutorService。要配置一个线程池是比较复杂的,尤其是对于线程池的原理不是很清楚的情况下,因此在工具类Executors 面提供了一些静态工厂方法,生成一些常用的线程池,如下所示:

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

(2)newFixedThreadPool:创建固定大小的线程池。每次提交一个任务就创建一个线程,直到线程达到线程池的最大大小。线程池的大小一旦达到最大值就会保持不变,如果某个线程因为执行异常而结束,那么线程池会补充一个新线程。如果希望在服务器上使用线程池,建议使用 newFixedThreadPool方法来创建线程池,这样能获得更好的性能。

(3) newCachedThreadPool:创建一个可缓存的线程池。如果线程池的大小超过了处理任务所需要的线程,那么就会回收部分空闲(60 秒不执行任务)的线程,当任务数增加时,此线程池又可以智能的添加新线程来处理任务。此线程池不会对线程池大小做限制,线程池大小完全依赖于操作系统(或者说 JVM)能够创建的最大线程大小。

(4)newScheduledThreadPool:创建一个大小无限的线程池。此线程池支持定时以及周期性执行任务的需求。

8.2-线程池的优点

  • 降低资源消耗:重用存在的线程,减少对象创建销毁的开销。

  • 提高响应速度。可有效的控制最大并发线程数,提高系统资源的使用率,同时避免过多资源竞争,避免堵塞。当任务到达时,任务可以不需要的等到线程创建就能立即执行。

  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

  • 附加功能:提供定时执行、定期执行、单线程、并发数控制等功能。

综上所述使用线程池框架 Executor 能更好的管理线程、提供系统资源使用率。

8.3-线程池有哪些状态?

  • RUNNING:这是最正常的状态,接受新的任务,处理等待队列中的任务。

  • SHUTDOWN:不接受新的任务提交,但是会继续处理等待队列中的任务。

  • STOP:不接受新的任务提交,不再处理等待队列中的任务,中断正在执行任务的线程。

  • TIDYING:所有的任务都销毁了,workCount 为 0,线程池的状态在转换为

  • TIDYING 状态时,会执行钩子方法 terminated()。TERMINATED:terminated()方法结束后,线程池的状态就会变成这个。

8.4-为什么要用线程池?

线程池提供了一种限制和管理资源(包括执行一个任务)。 每个线程池还维护一些基本统计信息,例如已完成任务的数量。

使用线程池的好处:

  • 降低资源消耗。 通过重复利用已创建的线程降低线程创建和销毁造成的消耗。

  • 提高响应速度。 当任务到达时,任务可以不需要的等到线程创建就能立即执行。

  • 提高线程的可管理性。 线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

8.5-执行 execute() 方法和 submit() 方法的区别是什么呢?

  • execute() 方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;

  • submit() 方法用于提交需要返回值的任务。线程池会返回一个 future 类型的对象,通过这个future 对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值,get()方法会阻塞当前线程直到任务完成,而使用 get(long timeout,TimeUnit unit)方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。

8.6-说下线程池核心参数?

  • corePoolSize : 核心线程大小。线程池一直运行,核心线程就不会停止。

  • maximumPoolSize :线程池最大线程数量。非核心线程数量=maximumPoolSize-corePoolSize

  • keepAliveTime :非核心线程的心跳时间。如果非核心线程在keepAliveTime内没有运行任务,非核心线程会消亡。

  • workQueue :阻塞队列。ArrayBlockingQueue,LinkedBlockingQueue等,用来存放线程任务。

  • defaultHandler :饱和策略。ThreadPoolExecutor类中一共有 4 种饱和策略。通过实现RejectedExecutionHandler 接口。

    • AbortPolicy : 线程任务丢弃报错。默认饱和策略。

    • DiscardPolicy : 线程任务直接丢弃不报错。

    • DiscardOldestPolicy : 将workQueue队首任务丢弃,将最新线程任务重新加入队列执行。

    • CallerRunsPolicy :线程池之外的线程直接调用run方法执行。

  • ThreadFactory :线程工厂。新建线程工厂。

8.7-线程池执行任务的流程?

  1. 线程池执行execute/submit方法向线程池添加任务,当任务小于核心线程数corePoolSize,线程池中可以创建新的线程。

  2. 当任务大于核心线程数 corePoolSize,就向阻塞队列添加任务。

  3. 如果阻塞队列已满,需要通过比较参数 maximumPoolSize,在线程池创建新的线程,当线程数量大于maximumPoolSize,说明当前设置线程池中线程已经处理不了了,就会执行饱和策略。

8.8-常用的 JAVA 线程池有哪几种类型?

newCachedThreadPool

创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。

这种类型的线程池特点是:

工作线程的创建数量几乎没有限制(其实也有限制的,数目为 Interger. MAX_VALUE), 这样可灵活的往线程池中添加线程。

如果长时间没有往线程池中提交任务,即如果工作线程空闲了指定的时间(默认为 1 分钟),则该工作线程将自动终止。终止后,如果你又提交了新的任务,则线程池重新创建一个工作线程。

在使用 CachedThreadPool 时,一定要注意控制任务的数量,否则,由于大量线程同时运行,很有会造成系统OOM。

2newFixedThreadPool

创建一个指定工作线程数量的线程池。每当提交一个任务就创建一个工作线程,如果工作线程数量达到线程池初始的最大数,则将提交的任务存入到池队列中。

FixedThreadPool 是一个典型且优秀的线程池,它具有线程池提高程序效率和节省创建线程时所耗的开销的优点。但是,在线程池空闲时,即线程池中没有可运行任务时,它不会释放工作线程,还会占用一定的系统资源。

3newSingleThreadExecutor

创建一个单线程化的 Executor,即只创建唯一的工作者线程来执行任务,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。如果这个线程异常结束,会有另一个取代它,保证顺序执行。单工作线程最大的特点是可保证顺序地执行各个任务,并且在任意给定的时间不会有多个线程是活动的。

4newScheduleThreadPool

创建一个定长的线程池,而且支持定时的以及周期性的任务执行,支持定时及周期性任务执行。

8.9-线程池常用的阻塞队列有哪些?

  1. LinkedBlockingQueue

      对于 FixedThreadPool 和 SingleThreadExector 而言,它们使用的阻塞队列是容量为 Integer.MAX_VALUE 的 LinkedBlockingQueue,可以认为是无界队列。由于 FixedThreadPool 线程池的线程数是固定的,所以没有办法增加特别多的线程来处理任务,这时就需要LinkedBlockingQueue 这样一个没有容量限制的阻塞队列来存放任务。这里需要注意,由于线程池的任务队列永远不会放满,所以线程池只会创建核心线程数量的线程,所以此时的最大线程数对线程池来说没有意义,因为并不会触发生成多于核心线程数的线程。

  2. SynchronousQueue 第二种阻塞队列是 SynchronousQueue,对应的线程池是 CachedThreadPool。线程池CachedThreadPool 的最大线程数是 Integer 的最大值,可以理解为线程数是可以无限扩展的。CachedThreadPool 和上一种线程池 FixedThreadPool 的情况恰恰相反,FixedThreadPool 的情况是阻塞队列的容量是无限的,而这里 CachedThreadPool 是线程数可以无限扩展,所以CachedThreadPool 线程池并不需要一个任务队列来存储任务,因为一旦有任务被提交就直接转发给线程或者创建新线程来执行,而不需要另外保存它们。我们自己创建使用 SynchronousQueue 的线程池时,如果不希望任务被拒绝,那么就需要注意设置最大线程数要尽可能大一些,以免发生任务数大于最大线程数时,没办法把任务放到队列中也没有足够线程来执行任务的情况。

8.10-源码中线程池是怎么复用线程的?

源码中 ThreadPoolExecutor 中有个内置对象 Worker,每个 worker 都是一个线程,worker线程数量和参数有关,每个 worker 会 while 死循环从阻塞队列中取数据,通过置换 workerRunnable 对象,运行其 run 方法起到线程置换的效果,这样做的好处是避免多线程频繁线程切换,提高程序运行性能。

8.11-如何合理配置线程池参数?

自定义线程池就需要我们自己配置最大线程数 maximumPoolSize ,为了高效的并发运行,这时需要看我们的业务是IO 密集型还是 CPU 密集型。

CPU 密集型 CPU密集的意思是该任务需要最大的运算,而没有阻塞,CPU 一直全速运行。CPU 密集任务只有在真正的多核 CPU上才能得到加速(通过多线程)。而在单核 CPU 上,无论你开几个模拟的多线程该任务都不可能得到加速,因为 CPU总的运算能力就那么多。

IO 密集型 IO 密集型,即该任务需要大量的 IO,即大量的阻塞。在单线程上运行 IO 密集型的任务会导致大量的 CPU 运算能力浪费在等待。所以在 IO 密集型任务中使用多线程可以大大的加速程序运行,即使在单核 CPU 上这种加速主要就是利用了被浪费掉的阻塞时间。

IO 密集型时,大部分线程都阻塞,故需要多配制线程数。公式为:

当以上都不适用时,选用动态化线程池,看美团技术团队的实践:Java线程池实现原理及其在美团业务中的实践 - 美团技术团队

8.12-Executor 和 Executors 的区别?

  • Executors 工具类的不同方法按照我们的需求创建了不同的线程池,来满足业务的需求。

  • Executor 接口对象能执行我们的线程任务。

  • ExecutorService 接口继承了 Executor 接口并进行了扩展,提供了更多的方法我们能获得任务执行的状态并且可以获取任务的返回值。

  • 使用 ThreadPoolExecutor 可以创建自定义线程池。

  • Future 表示异步计算的结果,他提供了检查计算是否完成的方法,以等待计算的完成,并可以使用 get()方法获取计算的结果。

9-JMM

9.1-JMM定义

9.2-JMM内存交互操作

9.3-JMM内存可见性保证

9.4-JMM的内存屏障

10-atomic

10.1-介绍一下 Atomic 原子类

Atomic 是指一个操作是不可中断的。即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰;

所以,所谓原子类说简单点就是具有原子 / 原子操作特征的类;

并发包 java.util.concurrent 的原子类都存放在 java.util.concurrent.atomic 下:

10.2- JUC 包中的原子类是哪 4 类?

基本类型

使用原子的方式更新基本类型:

  • AtomicInteger : 整型原子类

  • AtomicLong: 长整型原子类

  • AtomicBoolean: 布尔型原子类

数组类型 使用原子的方式更新数组里的某个元素:

  • AtomicIntegerArray: 整型数组原子类

  • AtomicLongArray: 长整型数组原子类

  • AtomicReferenceArray: 引用类型数组原子类

引用类型 使用原子的方式更新引用类型:

  • AtomicReference: 引用类型原子类

  • AtomicStampedReference: 原子更新带有版本号的引用类型。该类将整型数值与引用关联起来,可用于解决原子的更新数据和数据的版本号,可以解决使用 CAS 进行原子更新时可能出现的ABA 问题。

  • AtomicMarkableReference: 原子更新带有标记位的引用类型。对象属性修改类型

  • AtomicIntegerFieldUpdater: 原子更新整型字段的更新器

  • AtomicLongFieldUpdater: 原子更新长整型字段的更新器

  • AtomicMarkableReference: 原子更新带有标记位的引用类型

10.3-简单介绍一下 AtomicInteger 类的原理

AtomicInteger 类主要利用 CAS和 volatile 和 native 方法来保证原子操作,从而避免 synchronized 的高开销,执行效率大为提升。

AtomicInteger 类的部分源码:

11-多线程与并发篇补充

11.1-lock前缀指令的作用

11.2-缓存一致性、总线仲裁、总线窥探

11.4lock#

标签:多线程,synchronized,Thread,笔记,面试,线程,执行,方法,CPU
From: https://blog.csdn.net/weixin_64713614/article/details/142819596

相关文章

  • 10月最新AI产品经理面试20个问题汇总(含面试解题技巧、注意事项)
    这题我会!这是一个包含AI产品经理问题的备考文章,本文主要讲解AI产品经理的备考注意事项、真题展示、解题技巧及高效刷题方法,相信大家看完就一定能掌握技巧并且顺利通关!一、AI产品经理面试问题展示(20道)\1.请描述一下你过去负责的一个AI产品开发项目,包括项目的目标、过程......
  • Spring事务的1道面试题
    每次聊起Spring事务,好像很熟悉,又好像很陌生。本篇通过一道面试题和一些实践,来拆解几个Spring事务的常见坑点。原理Spring事务的原理是:通过AOP切面的方式实现的,也就是通过代理模式去实现事务增强。具体过程是:对包含@Transactional注解的方法进行拦截,然后重写,重新在方法里加入异......
  • 《Programming from the Ground Up》阅读笔记:p217-p238
    《ProgrammingfromtheGroundUp》学习第11天,p217-p238总结,总计22页。一、技术总结1.Ccompilingp216,Ccompilingissplitintotwostages-thepreprocessorandthemaincompiler。注:感觉这个写法不好,因为preprocessor和compiler都是对象,这里应该指动作。应该是:Cco......
  • Bluespec SystemVerilog(BSV) 及 MIT 体系结构公开课 笔记
    前言早年MIT有三门用bsv作为硬件描述语言的体系结构课程,代号分别为6.004,6.175和6.375.根据MITCScourselist,现在这三门课分别改名为了6.1910、6.1920和6.5900.本文是自学这三门课所需的bsv时记录的笔记,内容主要来源于这三门课目前公开的资料(6.17516fall,6.375......
  • [编程笔记] 当前上下文中不存在名称"ViewBag"
    最近在弄另外一个项目,很长一段时间没接触MVC了,VisualStudio2022识别cshtml文件的时候,出了一点故障!很多ViewBag、@Html.Partial、@Html.FunctionBar()等这些地方都报波浪线了,提示不存在这个名称,但是代码是可以运行的,这种一般就是本地环境或者配置的问题了。......
  • 【Python脚本】getopt参数解析笔记
    getopt参数解析笔记背景在Python中,使用getopt模块进行命令行参数解析是常见的需求。在编写脚本时,正确地定义参数选项对于确保程序正常运行至关重要。这是一个用于检测安卓软件版本的脚本一部分,有些用法和笔记分享给大家问题描述在某个脚本中,使用getopt解析命令......
  • 掌握未来:2025年秋招LLM及多模态模型面试精华
    目录大模型常用微调方法LoRA和Ptuning的原理介绍StableDiffusion的原理为何现在的大模型大部分是Decoder-only结构如何缓解LLMs复读机问题为什么Transformer块使用LayerNorm而不是BatchNormTransformer为何使用多头注意力机制监督微调SFT后LLM表现下降的原因微调阶段样本......
  • 面试 - 速刷面试题 - 知识深度
    如何检测JS内存泄漏?JS内存泄露场景有哪些?垃圾回收GC:引用计数算法、标记清除。引用计数......
  • 记录一道面试题(哈希表 稀疏矩阵)
    题目:有一个游戏中的三维地图,是由i,j,k三个轴组成的三维网络。每个立方体由不同的种类代表,比如空气,水,沙子,泥土。地图上方的空气方块,不会经常变动且数量占大多数,下方是各种类型的方块,会经常相互转换(水变沙子,沙子变泥土等)。问题:请你实现一个存储该地图的方案(地图方块和对应类型)。要......
  • 面试 - 补充 - HTML/CSS(可能问到的题目展示)
    如何理解HTML语义化?默认情况下,哪些元素是块级元素,哪些是内联元素?盒模型宽度如何计算?margin纵向重叠的问题margin负值的问题BFC理解和应用float布局的问题flex画色子absolute和relative依据什么定位?居中对齐有哪些实现方式line-height继承(有坑)rem是什么如何实现响应式......