java-多线程
2020-10-28 11:24:45 2 举报
AI智能生成
面试中多线程相关问题(持续更新)
作者其他创作
大纲/内容
多线程
概念
关系
运行一个程序会产生一个进程,进程包含至少一个线程
每个进程对应一个JVM实例,多个线程共享JVM里的堆
Java采用单线程编程模型,程序会自动创建主线程
主线程可以创建子线程,原则上要后于子线程完成执行
线程和进程
进程是资源分配的最小单位,线程是CPU调度的最小单位
线程不能看作独立应用,而进程可看作独立应用
进程有独立的地址空间,相互不影响,线程只是进程的不同执行路径
线程没有独立的地址空间,多进程的程序比多线程程序健壮
进程的切换比线程的开销大
临界资源
临界资源是一次仅允许一个进程使用的共享资源。各进程采取互斥的方式,实现共享的资源称作临界资源
临界区
每个进程中访问临界资源的那段代码称为临界区(criticalsection),每次只允许一个进程进入临界区,进入后,不允许其他进程进入。
线程安全主要诱因
1、存在共享数据(也称临界资源)
2、存在多条线程共同操作这些共享数据
进程间通信方式
管道通信
匿名管道
命名管道
半双工的,即数据只能在一个方向上流动,只能用于具有亲缘关系的进程之间的通信,可以看成是一种特殊的文件,对于它的读写也可以使用普通的read、write 等函数。但是它不是普通的文件,并不属于其他任何文件系统,并且只存在于内存中。
FIFO命名管道
FIFO是一种文件类型,可以在无关的进程之间交换数据,与无名管道不同,FIFO有路径名与之相关联,它以一种特殊设备文件形式存在于文件系统中。
消息队列
消息队列,是消息的链接表,存放在内核中。一个消息队列由一个标识符(即队列ID)来标识。
信号量
信号量是一个计数器,信号量用于实现进程间的互斥与同步,而不是用于存储进程间通信数据。
共享内存
共享内存指两个或多个进程共享一个给定的存储区,一般配合信号量使用。
简单介绍进程的切换过程
1.切换新的页表,然后使用新的虚拟地址空间
2.切换内核栈,加入新的内容(PCB控制块,资源相关),硬件上下文切换
实质上就是被中断运行进程与待运行进程的上下文切换
缺页中断大概有三种算法:
(OPT)最晚不使用的算法:表示新的页如果进来没内存框可放了,会替换之后 几乎或者根本不会用到的页。但这种算法只是一种理想算法,因为没人能预估之后的事,包括计算机
FIFO)先进先出算法:表示没来一个新的页,他都会替换最早进来的那个页。这种办法有个缺点:如果最早进来的那个页是经常被访问的,那么一定情况下效率会比较低。所以FIFO算法在按 线性顺序访问地址空间时使用
(LRU)最常不使用算法:这种办法,会找出当前内存中,最经常没被使用的那个页。然后替换他。这种办法是前两个算法的折中选择,基本现在都用这种
线程通信方式
博客
特性
可见性
保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
有序性
禁止进行指令重排序
存在于java.util.cucurrent包下
countDownLatch这个类使一个线程等待其他线程各自执行完毕后再执行。是通过一个计数器来实现的,计数器的初始值是线程的数量。每当一个线程执行完毕后,计数器的值就-1,当计数器的值为0时,表示所有线程都执行完毕,然后在闭锁上等待的线程就可以恢复工作了。
几种重要方法
countDownLatch是一个计数器,线程完成一个记录一个,计数器递减,只能只用一次
概要
CyclicBarrier的计数器更像一个阀门,需要所有线程都到达,然后继续执行,计数器递增,提供reset功能,可以多次使用
volatile 关键字
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
使用 Object 类的wait() 和 notify() 方法
使用 ReentrantLock 结合 Condition的 await() 和 signal() 方法
信号量 Semaphore,可以控制对共享资源的并发访问度,有 accquire() 和 release() 方法
CountDownLatch:控制线程等待,计数器功能,可以用来等待多个线程执行任务后进行汇总
CyclicBarrier:类似CountDownLatch但更强大,可以重复使用,控制多个线程,一般测试使用
基本LockSupport实现线程间的阻塞和唤醒
死锁
死锁的4个必要条件⭐
1、互斥条件:一个资源每次只能被一个线程使用;
2、请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源保持不放;
3、不剥夺条件:进程已经获得的资源,在未使用完之前,不能强行剥夺;
4、循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
定义
死锁就是一个进程集合中的多个进程因为竞争资源,而造成的互相等待现象
死锁的原因
系统资源不足;多个进程的推进顺序不合理;资源分配不当
处理死锁的方法
预防死锁
破坏四个必要条件之一即可
避免死锁
资源按序分配
银行家算法
检测和解除死锁
1) 资源剥夺法
2) 撤消进程法
3)进程回退法
如何分析是否有线程死锁? ⭐常用的线程分析工具与方法
使用jconsole图形化工具直接检查死锁
Jconsole是JDK自带的图形化界面工具,使用JDK给我们的的工具JConsole,可以通过打开cmd然后输入jconsole打开。
使用jstack命令行分析线程Dump信息
Jstack是JDK自带的命令行工具,主要用于线程Dump分析。
理解线程的同步与异步、阻塞与非阻塞
同步与异步的区别是任务是否在同一个线程中执行的
线程之间的操作
阻塞与非阻塞是对同一个线程来说的,在某个时刻,线程要么处于阻塞,要么处于非阻塞
阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。
创建线程有哪几种方式,如何实现?
①. 继承Thread类创建线程类
- 定义Thread类的子类,并重写该类的run方法,该run方法的方法体就代表了线程要完成的任务。因此把run()方法称为执行体。- 创建Thread子类的实例,即创建了线程对象。- 调用线程对象的start()方法来启动该线程。
②. 通过Runnable接口创建线程类
- 定义runnable接口的实现类,并重写该接口的run()方法,该run()方法的方法体同样是该线程的线程执行体。- 创建 Runnable实现类的实例,并依此实例作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象。- 调用线程对象的start()方法来启动该线程。
③. 通过Callable和Future创建线程
- 创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,并且有返回值。- 创建Callable实现类的实例,使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。- 使用FutureTask对象作为Thread对象的target创建并启动新线程。- 调用FutureTask对象的get()方法来获得子线程执行结束后的返回值。
④. 通过线程池创建线程
- 调用Executors.newFixedThreadPool方法创建线程池。- Runnable的匿名内部类创建线程。- 结束要调用shutdown关闭线程池。
区别
相同点
都是接口
都采用Thread.start()启动线程
不同点
Runnable 接口 run 方法无返回值;Callable 接口 call 方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果
Runnable 接口 run 方法只能抛出运行时异常,且无法捕获处理;Callable 接口 call 方法允许抛出异常,可以获取异常信息
LockSupport
sleep和wait的区别⭐
sleep是Thread类的方法,wait是Object类中定义的方法
sleep()方法可以在任何地方使用,必须timeout时间,时间到了会自动唤醒
wait()方法只能在synchronized方法或者synchronized块中使用,没设置时间的话需要自动唤醒
Thread.sleep只会让出CPU,不会导致锁行为的改变
Object.wait()不仅让出CPU,还会释放已经占有的同步资源锁
notify()和 notifyAll()有什么区别
两个概念
锁池EntiyList
当一个线程需要调用调用此方法时必须获得该对象的锁,而该对象的锁被其他线程占用,该线程就需要在一个地方等待锁释放,这个地方就是锁池。(准备抢锁的池子)
等待池WaitSet
调用了wait方法的线程会释放锁并进入等待池,在等待池的线程不会竞争锁。(休息的池子)
notifyAll会让所有处于等待池中的线程全部进入锁池去竞争获取锁的机会
notify只会随机选取一个处于等待池中的线程进入锁池去竞争获取锁的机会
线程的 run()和 start()有什么区别?为什么不能直接调用 run() 方法?
流程
start()方法启动的线程是处于就绪状态, 但并没有运行。 然后通过此Thread类调用方法run()来完成其运行状态, 这里方法run()称为线程体,它包含了要执行的这个线程的内容, run方法运行结束, 此线程终止。然后CPU再调度其它线程。
start和run方法的区别
调用start()方法会去创建一个新的子线程并启动
run()方法只是Thread的一个普通方法的调用
Condition.await()
和Object.wait()类似
会释放锁
带时间不带时间
带时间
自动唤醒
通过另一个线程signal()
不带时间
只能被其他线程signal()
底层调用LockSupport().park()实现阻塞
signal()不能再其之前执行
方法声明有中断异常
需要在lock块中执行
LockSupport.park()
不会释放锁资源
可以通过unpark()唤醒或者超时自动唤醒
唤醒后一定会执行后续代码
方法声明不带中断异常
线程的生命周期和状态
新建(New)
创建后尚未启动的状态
运行(Runnable)
包含Running和Ready
Running:位于可运行线程中
Ready:处于ready状态的线程位于线程池中
无限等待(Waiting)
不会被分配CPU执行时间,需要显式被唤醒
没有设置Timeout参数的Object.wait()方法
没有设置Timeout参数的Thread.join()方法
LockSupport.park()方法
限期等待(Timed waiting)
在一定时间后会由系统自动唤醒
Thread.sleep()方法--存在timeout超时自动唤醒
设置了Timeout参数的Object.wait()方法
设置了Timeout参数的Thread.join()方法
LockSupport.parkNanos()方法
阻塞当前线程,最长不超过nanos纳秒,增加了超时返回的特性
LockSupport.parkUtil()方法
阻塞(Blocked)
等待获取排他锁
结束(Terminated)
已终止线程的状态,线程已经结束执行
线程的各种状态的切换(重要)⭐
1、得到一个线程类,new出一个实例线程就进入new状态(新建状态)。
2、调用start方法就进入Runnable(可运行状态)
3、如果此状态被操作系统选中并获得时间片就进入Running状态
4、如果Running状态的线程的时间片用完或者调用yield方法就可能回到Runnable状态
yield线程乐意释放自己的锁,给其他线程使用资源
5、处于Running状态的线程如果在进入同步代码块/方法就会进入Blocked状态(阻塞状态),锁被其它线程占有,这个时候被操作系统挂起。得到锁后会回到Running状态。
6、处于Running状态的线程如果调用了wait/join/LockSupport.park()就会进入等待池(无限期等待状态), 如果没有被唤醒或等待的线程没有结束,那么将一直等待。
7、处于Running状态的线程如果调用了sleep(睡眠时间)/wait(等待时间)/join(等待时间)/ LockSupport.parkNanos(等待时间)/LockSupport.parkUntil(等待时间)方法之后进入限时等待状态,等待时间结束后自动回到原来的状态。
8、处于Running状态的线程方法执行完毕或者异常退出就会进入死亡状态。
有哪几种实现生产者消费者模式的方法?
锁、信号量、线程通信、阻塞队列。
什么是上下文切换?⭐
多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式(程序计数器)。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。
线程池
创建线程池ThreadPoolExecutor有五种方式?
①. newFixedThreadPool(int nThreads)
创建一个固定线程数量的线程池,每当提交一个任务就创建一个线程,直到达到线程池的最大数量,这时线程规模将不再变化,新的任务会暂存在任务队列中,待有线程空闲时便处理任务。
②. newCachedThreadPool()
创建一个可缓存的线程池,如果线程池的规模超过了处理需求,将自动回收空闲线程,而当需求增加时,则可以自动添加新线程,线程池的规模不存在任何限制。
③. newSingleThreadExecutor()
这是一个单线程的Executor,它的特点是能确保依照任务在队列中的顺序来串行执行,适用于保证异步执行顺序的场景。
④. newScheduledThreadPool(int corePoolSize)(推荐)
创建了一个固定长度的线程池,以定时的方式来执行任务,适用于定期执行任务的场景。
⑤.newSingleThreadScheduledExecutor()
只有一个线程,用来调度任务在指定时间执行。
线程池都有哪些状态
TIDYING :所有的任务都已终止
TERMINATED : 结束方法terminated()执行完后进入该状态
线程池核心参数⭐
corePoolSize:线程池里的线程数量,核心线程池大小
maxPoolSize:线程池里的最大线程数量
workQueue: 任务队列,用于存放提交但是尚未被执行的任务。
keepAliveTime:当线程池中的线程数量大于 corePoolSize 时,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime 才会被回收销毁; 参数的时间单位为 unit。
阻塞队列种类
threadFactory:线程工厂,用于创建线程,一般可以用默认的
handler:拒绝策略
线程池的拒绝策略⭐
ThreadPoolExecutor.AbortPolicy:丢弃任务并抛出RejectedExecutionException异常。
ThreadPoolExecutor.DiscardPolicy:丢弃任务,但是不抛出异常。
ThreadPoolExecutor.DiscardOldestPolicy:丢弃队列最前面的任务,然后重新提交被拒绝的任务
ThreadPoolExecutor.CallerRunsPolicy:由提交任务的线程直接处理该任务
如何向线程池提交任务
**有2种**:分别使用execute 方法和 submit 方法
执行execute()方法和submit()方法的区别是什么呢?
1、execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功与否;
2、submit()方法用于提交需要返回值的任务。线程池会返回一个 Future 类型的对象,通过这个 Future 对象可以了解任务执行情况**,并且可以通过 Future 的 get() 方法来获取返回值,还可以取消任务执行。底层也是通过 execute() 执行的。
线程池常用的阻塞队列?
先进先出
ArrayBlockingQueue:基于数组实现的一个单端阻塞队列,只能从队尾出队,有界
LinkedBlockingQueue:基于链表实现的一个双端阻塞队列,分别从队头队尾操作入队出队。无界
PriorityBlockingQueue:以上2种队列都是先进先出队列,而PriorityBlockingQueue却不是,它会按照元素的优先级对元素进行排序,按照优先级顺序出队,每次出队的元素都是优先级最高的元素。注意,此阻塞队列为无界阻塞队列,即容量没有上限(通过源码就可以知道,它没有容器满的信号标志),前面2种都是有界队列。
DelayQueue:基于PriorityQueue,一种延时阻塞队列,DelayQueue中的元素只有当其指定的延迟时间到了,才能够从队列中获取到该元素。DelayQueue也是一个无界队列,因此往队列中插入数据的操作(生产者)永远不会被阻塞,而只有获取数据的操作(消费者)才会被阻塞。
为什么要使用线程池⭐
1、减少创建和销毁线程的次数,提高效率,每个工作线程都可以被重复利用,可执行多个任务。
2、可以根据系统的承受能力,调整线程池中工作线程的数目,放置因为消耗过多的内存,而把服务器累趴下
线程池满了,往线程池里提交任务会发生什么样的情况,具体分几种情况
向线程池提交一个线程的原理/步骤⭐
如何指定多个线程的执行顺序/ 如何控制线程池线程的优先级 ⭐
1、原始实现
1、设定一个 orderNum,每个线程执行结束之后,更新 orderNum,指明下一个要执行的线程。并且唤醒所有的等待线程。
2、在每一个线程的开始,要 while 判断 orderNum 是否等于自己的要求值,不是,则 wait,是则执行本线程。
2、使用join方法
使用join方法,让当前执行线程等待直到调用join()方法的线程结束运行,join()方法的主要作用是同步,使得线程之间的并行执行变为串行执行
3、使用单线程池
使用单线程池,这样就能根据传入的顺序执行线程
线程池的线程数量怎么确定
1、一般来说,如果是CPU密集型应用,则线程池大小设置为N+1。
2、一般来说,如果是I/O密集型应用,则线程池大小设置为2N+1。
3、在IO优化中,线程等待时间所占比例越高,需要越多线程,线程CPU时间所占比例越高,需要越少线程。这样的估算公式可能更适合:最佳线程数目 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目
怎么保证多线程的运行安全/保证线程安全的方法⭐
前提是保证下面三个方面:
有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序,(happens-before原则)。
可以使用 CAS、Synchronized、Lock、ThreadLocal 来实现。
如何尽可能提高多线程并发性能?
尽量减少临界区范围、使用ThreadLocal、减少线程切换、使用读写锁或CopyOnWrite机制
Java 中是如何实现线程同步的?
1.同步方法 synchronized 关键字修饰的方法(悲观锁)
2.使用特殊域变量(volatile)实现线程同步(保持可见性,多线程更新某一个值时,比如说线程安全单例双检查锁)
3.ThreadLocal(每个线程获取的都是该变量的副本)
4.使用重入锁实现线程同步(相对 synchronized 锁粒度更细了,效率高) 一个java.util.concurrent 包来支持同步。 ReentrantLock 类是可重入、互斥、实现了 Lock 接口的锁 ReentrantLock() : 创建一个 ReentrantLock 实例 lock() : 获得锁 unlock() : 释放锁
5.java.util.concurrent.atomic 包 (乐观锁) 方便程序员在多线程环境下,无锁的进行原子操作
关键字相关问题
Synchronized与Lock的区别⭐
synchronized是java内置关键字在jvm层面,Lock是个java类。
synchronized无法判断是否获取锁的状态,Lock可以判断是否获取到锁,并且可以主动尝试去获取锁。
synchronized会自动释放锁,Lock需在finally中手工释放锁(unlock()方法释放锁),否则容易造成线程死锁。
ReentrantLock更加灵活,提供了超时获取锁,可中断锁,在获取不到锁的情况会自己结束,而synchronized不可以
synchronized的锁可重入、不可中断、非公平,而Lock锁可重入、可判断、可公平(两者皆可)
????
ThreadLocal
ThreadLocal 实现原理
尽量在代理中使用try-finally块进行回收
synchronized
获取锁的分类
获取对象锁的两种方法:
获取类锁的两种方法:
\t* 同步代码块(synchronized(类.class)),锁是小括号()中的类对象(Class对象)\t* 同步静态方法(synchronized static method),锁是当前对象的类对象(Class对象)
类锁和对象锁总结
底层实现
java对象头
Monitor
每个java对象天生自带了一把看不见的锁
偏向锁,轻量级锁,重量级锁
ReentrantLock(再入锁)
位于java.util.concurrent.locks包
能够实现比synchronized更细力度的空值,如空值fairness
调用lock()之后,必须调用unlock()
性能未必比synchronized高,并且也是可重入的
将锁对象化
判断是否有线程,或者某个特定线程,在排队等待获取锁
带超时的获取锁的尝试
感知有没有成功获取锁
和synchronized的区别
JMM
指令重排序需要满足的条件
在单线程环境下不能改变程序的运行结果
存在数据依赖关系的不允许重排序
无法通过happens-before推倒出来的,才能进行指令重排序
happens-before八大原则
volatile和synchronized区别
1、volatile 是变量修饰符;synchronized 可以修饰类、方法、变量。
2、volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量的修改可见性和原子性。
3、volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。
4、volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化。
CAS(compare and swap)
当且仅当V的值等于A时,CAS通过原子的方式用新值B来更新V的值,否则不会执行任何操作,一般情况下是自旋操作
是一种有名的无锁算法,无锁变成,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步
涉及操作数
需要读写的内存值V
进行比较的值A
拟写入的新值B
ReentrantLock和synchronized区别
(1)synchronized是独占锁,加锁和解锁的过程自动进行,易于操作,但不够灵活。ReentrantLock也是独占锁,加锁和解锁的过程需要手动进行,不易操作,但非常灵活。
(2)synchronized可重入,因为加锁和解锁自动进行,不必担心最后是否释放锁;ReentrantLock也可重入,但加锁和解锁需要手动进行,且次数需一样,否则其他线程无法获得锁。
(3)synchronized不可响应中断,一个线程获取不到锁就一直等着;ReentrantLock可以相应中断。ReentrantLock还可以实现公平锁机制也就是在锁上等待时间最长的线程将获得锁的使用权。通俗的理解就是谁排队时间最长谁先执行获取锁。
经典问题
i++是线程安全的吗?
局部变量肯定是线程安全的(原因:方法内局部变量是线程私有的)
成员变量多个线程共享时,就不是线程安全的(原因:成员变量是线程共享的,因为 i++ 是三步操作。) 读值,+1,写值。在这三步任何之间都可能会有CPU调度产生,造成i的值被修改,造成脏读脏写。
乐观锁缺点
1、ABA问题
解决
AtomicStampedReference通过版本号判断
2、循环时间长开销大
自旋CAS如果长时间不成功,会给CPU带来非常大的开销
3、只能保证一个共享变量的原子操作
jdk1.6变化
单例模式的三种模式
饿汉式
饿汉模式是线程安全的,因为实例对象在类加载过程中就会被创建,在getInstance()方法中只是直接返回对象引用。之所以被称为“饿汉”,是因为这种模式创建实例对象比较“急”,真的是饿坏了……
好处
简单明了,无需关注线程安全问题
缺点
如果在一个大环境下使用了过多的饿汉单例,则会生产出过多的实例对象,无论你是否要使用他们。
饱汉式
饱汉模式不是线程安全的,因为是在需要的时候才会产生实例对象,生产之前会判断对象引用是否为空,这里,如果多个线程同时进入判断,就会生成多个实例对象,这是不符合单例的思想的。所以饱汉模式为了保证线程安全,就用synchronized关键字标识了方法。之所以被称为“饱汉”,因为它很饱,不急着生产实例,在需要的时候才会生产。
延时加载,用的时候才会生产对象。
坏处
需要保证同步,付出效率的代价。
双重锁模式(DCL )
双重锁模式,是饱汉模式的优化,进行双重判断,当已经创建过实例对象后就无需加锁,提高效率。也是一种推荐使用的方式
AQS
AQS是一个用来构建锁和同步器的框架,使用AQS能简单且高效地构造出应用广泛的大量的同步器
如何采用单线程的方式处理高并发
在单线程模型中,可以采用非阻塞I/O来提高单线程处理多个请求的能力,然后再采用事件驱动模型,基于异步回调来处理事件来
0 条评论
回复 删除
下一页