Java后端高频知识点学习笔记3---多线程

参考地址:牛_客_网
https://www.nowcoder.com/discuss/819302

1、线程和进程有什么区别

进程

线程

1

系统资源调度的最小单位

CPU调度的最小单位

2

一个进程可以包含多个线程

一个线程从属于一个进程

3

一个进程挂掉,不会影响其他进程

一个线程挂掉,对应的进程挂掉

4

进程在执行时拥有独立的内存单元

多个线程共享进程的内存

5

进程的系统开销大于线程的开销

线程需要的系统资源较少

6

进程和线程的通信方式不一样

2、进程之间的通信方式

进程间通信主要有以下7种方式:管道/匿名管道、有名管道、信号、消息队列、共享内存、信号量、Socket

管道/匿名管道(Pipes):管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用;进程的亲缘关系通常是指父子进程关系,⽤于具有亲缘关系的⽗⼦进程之间的通信

有名管道(Names Pipes):(半双工)匿名管道由于没有名字,只能⽤于亲缘关系的进程间通信;为了克服这个缺点,提出了有名管道;有名管道严格遵循先进先出(first in first out);有名管道不同于匿名管道之处在于它提供了一个路径名与之关联,以有名管道的文件形式存在于文件系统中,这样,即使与有名管道的创建进程不存在亲缘关系的进程,只要可以访问该路径,就能够彼此通过有名管道相互通信,因此,通过有名管道,不相关的进程也能交换数据,有名管道以磁盘⽂件的⽅式存在,可以实现本机任意两个进程通信;有名管道的名字存在于文件系统中,内容存放在内存中

信号(Signal)信号是进程间互相通信或者操作的一种机制,信号可以在任何时候发给某一进程,而无需知道该进程的状态;如果该进程当前未处于执行状态,则该信号就由内核保存起来,直到该进程恢复执行并传递给它为止;如果一个信号被进程设置为阻塞,则该信号的传递被延迟,直到其阻塞被取消时才被传递给进程

消息队列(Message Queuing):消息队列是消息的链表,具有写权限的进程可以按照一定的规则向消息队列中添加新数据,对消息队列有读权限的进程则可以从消息队列中读取数据

信号量(Semaphores) :信号量是⼀个计数器,⽤于多进程对共享数据的访问,信号量的意图在于进程间同步;这种通信⽅式主要⽤于解决与同步相关的问题并避免竞争条件(信号量也用来解决互斥和同步问题);对信号量的操作分为P操作(减1)和V操作(加1),P操作是将信号量的值减1,V操作是将信号量的值加1;当信号量的值小于等于0之后,再进行P操作,当前进程或线程会被阻塞,直到另一个进程或线程执行了V操作将信号量的值增加到大于0之时,才可以进行P操作;锁也是用的这种原理实现的

共享内存(Shared memory):进程间本身的内存是相互隔离的,而共享内存机制相当于给两个进程开辟了一块二者均可以访问的内存空间,这时两个进程便可以共享一些数据,但是多进程同时占用资源会带来一些意料之外的情况,这时,往往需要用互斥锁和信号量来控制对内存空间的访问;可以说这是最有⽤的进程间通信⽅式

套接字(Sockets) : 此⽅法主要⽤于在客户端和服务器之间通过⽹络进⾏通信;用于网络中不同机器之间进程间的通信

套接字是⽀持TCP/IP 的⽹络通信的基本操作单元,可以看做是不同主机之间的进程进⾏双向通信的端点,简单的说就是通信的两⽅的⼀种约定,⽤套接字中的相关函数来完成通信过程

3、线程之间的通信方式

Java中线程通信主要用以下3种方式:

① wait()、notify()、notifyAll()

如果线程之间采用synchronized来保证线程安全,则可以利用wait()、notify()、notifyAll()来实现线程通信

  • wait()方法可以让当前线程释放对象锁并进入阻塞状态
  • notify()方法用于唤醒一个正在等待相应对象锁的线程,使其进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到CPU的执行
  • notifyAll()用于唤醒所有正在等待相应对象锁的线程,使它们进入就绪队列,以便在当前线程释放锁后竞争锁,进而得到CPU的执行

每个锁对象都有两个队列,一个是就绪队列,一个是阻塞队列;就绪队列存储了已就绪(将要竞争锁)的线程,阻塞队列存储了被阻塞的线程;当一个阻塞线程被唤醒后,才会进入就绪队列,进而等待CPU的调度;反之,当一个线程被wait()后,就会进入阻塞队列,等待被唤醒

② await()、signal()、signalAll()

如果线程之间采用Lock来保证线程安全,则可以利用await()、signal()、signalAll()来实现线程通信

这三个方法都是Condition接口中的方法,该接口是在Java 1.5中出现的,它用来替代传统的 wait() + notify()/notifyAll() 实现线程间的协作,它的使用依赖于Lock;相比使用 wait() + notify()/notifyAll(),使用Condition的await() + signal()/signalAll() 这种方式能够更加安全和高效地实现线程间协作

Condition依赖于Lock接口,生成一个Condition的基本代码是lock.newCondition();必须要注意的是,Condition 的 await()/signal()/signalAll() 使用都必须在lock保护之内,也就是说,必须在lock.lock()和lock.unlock()之间才可以使用。事实上,await()/signal()/signalAll() 与 wait()/notify()/notifyAll()有着天然的对应关系;

  • Conditon中的await()对应Object的wait(),
  • Condition中的signal()对应Object的notify(),
  • Condition中的signalAll()对应Object的notifyAll()

③ BlockingQueue

Java 5提供了一个BlockingQueue接口,虽然BlockingQueue也是Queue的子接口,但它的主要用途并不是作为容器,而是作为线程通信的工具;BlockingQueue具有一个特征:

  • 当生产者线程试图向BlockingQueue中放入元素时,如果该队列已满,则该线程被阻塞;
  • 当消费者线程试图从BlockingQueue中取出元素时,如果该队列已空,则该线程被阻塞。

程序的两个线程通过交替向BlockingQueue中放入元素、取出元素,即可很好地控制线程的通信。线程之间需要通信,最经典的场景就是生产者与消费者模型,而BlockingQueue就是针对该模型提供的解决方案

4、创建线程的四种方式

1、继承Thread类
2、实现Runnable接口
3、实现Callable接口
4、使用线程池创建

通过继承Thread类、实现Runnable接口、实现Callable接口都可以实现多线程,实现Runnable接口与实现Callable接口的方式基本相同,只是Callable接口里定义的方法有返回值,可以声明抛出异常而已;因此可以将实现Runnable接口和实现Callable接口归为一种方式

采用实现Runnable、Callable接口的方式创建多线程的优缺点:

  • 优势:
    线程类只是实现了 Runnable接口 或 Callable接口,还可以继承其他类;
    在这种方式下,多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想
  • 劣势:
    编程稍稍复杂,如果需要访问当前线程,则必须使用Thread.currentThread()方法;

采用继承Thread类的方式创建多线程的优缺点:

  • 优势:
    编写简单,如果需要访问当前线程,则无须使用Thread.currentThread()方法,直接使用this即可获得当前线程。
  • 劣势:
    因为线程类已经继承了Thread类,所以不能再继承其他父类

鉴于上面分析,因此一般 推荐采用实现Runnable接口、Callable接口的方式来创建多线程

5、线程中start()和run()的区别

start() ;它的作用是启动一个新线程
通过Thread类中的start()方法来启动新线程,新线程处于就绪(可运行)状态,并没有运行,一旦得到cpu时间片,就开始执行线程的run()方法;start()不能被重复调用;
用start()方法来启动新线程,真正实现了多线程运行,即无需等待 新建线程的run()方法体 执行完毕,就直接继续当前线程下面的代码;

run()方法称为线程体,它包含了该线程要执行的内容,run()方法运行结束,此线程随即终止;和普通的成员方法一样,可以被重复调用
如果直接调用run方法,并不会启动新线程!程序中依然只有主线程这一个线程,其程序执行路径还是只有一条,还是要顺序执行,还是要等待run方法体执行完毕后才可继续执行下面的代码,这样就没有达到多线程的目的

总结:调用start()方法方可启动新线程,而调用run()方法则是线程对象的一个普通方法,还是在主线程里执行,不会启动新线程

6、线程的生命周期和状态

① 新建:线程对象已经创建,但是还没有调用start()方法

② 就绪:线程对象调用start()方法,但是还未开始运行就是就绪状态;一旦获取CPU时间片,就开始运行

③ 运行:当线程对象调用了start()方法,并且获取了CPU时间片,就是运行状态

③ 阻塞:等待获取一个排它锁,如果其线程释放了锁就会结束此状态;如调用wait()方法进入阻塞状态

⑥ 死亡:可以是线程结束任务后自己结束,或者产生了异常而结束

7、什么是线程死锁?如何避免死锁?

死锁:两个或两个以上的线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,这些线程都陷入无限的等待中

产生死锁的4个必要条件:
互斥条件:某一资源在任意⼀个时刻只由⼀个线程占⽤
请求与保持条件:⼀个线程因请求资源⽽阻塞时,对已获得的资源保持不放
不可剥夺条件:线程已获得的资源在末使⽤完之前不能被其他线程强⾏剥夺,只有⾃⼰使⽤完毕后才释放资源
循环等待条件:若⼲线程之间形成⼀种头尾相接的循环等待资源关系

如何避免线程死锁?
为了避免死锁,我们只要破坏产⽣死锁的四个条件中的其中⼀个就可以了
① 破坏互斥条件 :这个条件我们没有办法破坏,因为我们⽤锁本来就是想让他们互斥的(临界资源需要互斥访问)
破坏请求与保持条件 :⼀次性申请所有的资源(银行家算法)
破坏不剥夺条件 :占⽤部分资源的线程进⼀步申请其他资源时,如果申请不到,可以主动释放它占有的资源
④ 破坏循环等待条件 :靠按序申请资源来预防;将系统中的所有资源统一编号,进程可在任何时刻提出资源申请,但所有申请必须按照资源的编号顺序(升序或降序)提出

避免死锁最简单的方法就是 破坏循环等待条件 ,将系统中所有的资源设置标志位、排序, 规定所有的进程申请资源必须以一定的顺序(升序或降序) 做操作来避免死锁

8、sleep() 和 wait() 的区别

  • sleep()是属于Thread类中的方法;它可以让当前正在执行的线程在指定的时间内暂停执行,进入阻塞状态,但是在此期间线程不会释放锁,只会阻塞线程,当指定的时间到了又会自动恢复运行状态,可中断,sleep()给其他线程运行机会时不考虑线程的优先级,高优先级和低优先级的线程都有机会执行
  • wait()是属于Object 类中的方法,线程会释放对象锁,只有当其他线程调用 notify() 或 notifyAll() 才能唤醒此线程;wait()方法在使用时必须先获取对象锁,即必须在 synchronized 修饰的方法或代码块中使用,那么相应的 notify() 或 notifyAll() 方法同样必须在 synchronized 修饰的代码块中使用,如果没有在 synchronized 修饰的代码块中使用时运行时会抛出IllegalMonitorStateException的异常

9、wait()方法为什么要在while()循环中使用

wait()方法之所以要用while(){}而不是if(){}

  • 原因:当多个线程并发访问同一个资源的时候,若消费者同时被唤醒,但是只有一个资源可用,那么if(){}会导致判断存在资源可用后直接去获取资源(发生越界异常等),而while则会让每个消费者获取之前再去判断一下资源是否可用,可用则获取,不可用则继续wait();
  • 总结:判断竞态需要使用while循环进行判断,不能使用if

10、线程池

1、线程池的概念

线程池就是首先创建一些线程,它们的集合称为线程池;使用线程池可以很好地提高性能,线程池在系统启动时即创建大量空闲的线程,程序将一个任务传给线程池,线程池就会启动一条线程来执行这个任务,执行结束以后,该线程并不会死亡,而是再次返回线程池中成为空闲状态,等待执行下一个任务

2、线程池的工作机制

在线程池的编程模式下,任务是提交给整个线程池,而不是直接提交给某个线程,线程池在拿到任务后,就在内部寻找是否有空闲的线程,如果有,则将任务交给某个空闲的线程;一个线程同时只能执行一个任务,但可以同时向一个线程池提交多个任务

3、什么是线程复用?线程复用的原理

线程复用:在线程池中,通过同一个线程去执行不同的任务

线程复用的原理:在线程池中,同一个线程可以从阻塞队列中不断获取新任务来执行,其核心原理在于线程池对 Thread 进行了封装,并不是每次执行任务时都会调用 Thread.start() 来创建新线程,而是让每个线程去执行一个“循环任务”,在这个“循环任务”中不停的检查是否有任务需要被执行,如果有则直接执行,也就是调用任务中的 run()方法,将 run()方法当成一个普通的方法执行,通过这种方式将只使用固定的线程就将所有任务的 run()方法串联起来

总结:线程池将线程和任务进行解耦,摆脱了之前通过 Thread 创建线程时的一个线程必须对应一个任务的限制

11、为什么要使用线程池

多线程运行时间,系统不断的启动和关闭新线程,成本非常高,会过渡消耗系统资源,而且频繁切换线程可能导致系统资源的崩溃

(1) 降低资源消耗;通过重复利用已创建的线程降低线程创建和销毁造成的消耗
(2) 提高响应速度;当任务到达时,任务可以不需要等到线程创建就能立即执行
(3) 提高线程的可管理性;线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控

12、线程池有哪些参数?每个参数的作用是什么?

序号

参数

含义

解释

1

corePoolSize

核心工作线程数

当向线程池提交一个任务时,若线程池已创建的线程数小于corePoolSize,即便此时存在空闲线程,也会通过创建一个新线程来执行该任务,直到已创建的线程数大于或等于corePoolSize时

2

maximumPoolSize

最大线程数

线程池所允许的最大线程个数;当队列满了,且已创建的线程数小于maximumPoolSize,则线程池会创建新的线程来执行任务。另外,对于无界队列,可忽略该参数

3

keepAliveTime

多余线程存活时间

当线程池中线程数大于核心线程数时,线程的空闲时间如果超过线程存活时间,那么这个线程就会被销毁,直到线程池中的线程数小于等于核心线程数

4

unit

空闲线程存活时间单位

keepAliveTime的计量单位

5

workQueue

工作队列

用于传输和保存等待执行任务的阻塞队列;即任务被提交后,会先进入到此工作队列中,任务调度时再从队列中取出任务

6

threadFactory

线程创建工厂

用于创建新线程;threadFactory创建的线程也是采用newThread()方式,threadFactory创建的线程名都具有统一的风格: pool-m-thread-n (m为线程池的编号,n为线程池内的线程编号)

7

handler

拒绝策略

当线程池和队列都满了,再加入线程会执行此策略

13、线程池的拒绝策略和阻塞队列

默认的拒绝策略:AbortPolicy

当线程池的任务缓存队列已满并且线程池中的线程数目达到maximumPoolSize(最大线程数),如果还有任务到来就会采取任务拒绝策略,通常有以下四种策略:
1、AbortPolicy:丢弃任务并抛出RejectedExecutionException异常
2、DiscardPolicy:丢弃任务,但是不抛出异常
3、DiscardOldestPolicy:丢弃队列最前面的任务,然后重新尝试执行任务(重复该过程)
4、CallerRunsPolicy:由调用线程处理该任务

阻塞队列

① 第一种阻塞队列是 LinkedBlockingQueue;对应于线程池:newSingleThreadExecutor()和newFixedThreadPool(int n)

LinkedBlockingQueue,它的容量是 Integer.MAX_VALUE,为 2^31-1 ,是一个非常大的值,可以认为是无界队列

FixedThreadPool 和 SingleThreadExecutor 线程池的线程数是固定的,所以没有办法增加特别多的线程来处理任务,这时就需要 LinkedBlockingQueue 这样一个没有容量限制的阻塞队列来存放任务

② 第二种阻塞队列是 SynchronousQueue;对应的线程池是 newCachedThreadPool()

线程池 CachedThreadPool 的最大线程数是 Integer.MAX_VALUE,可以理解为线程数是可以无限扩展的

CachedThreadPool 和上一种线程池 FixedThreadPool 的情况恰恰相反,FixedThreadPool 的情况是阻塞队列的容量是无限的,而这里 CachedThreadPool 是线程数可以无限扩展,所以 CachedThreadPool 线程池并不需要一个任务队列来存储任务,因为一旦有任务被提交就直接转发给线程或者创建新线程来执行,而不需要另外保存它们

我们自己创建使用 SynchronousQueue 的线程池时,如果不希望任务被拒绝,那么就需要注意设置最大线程数要尽可能大一些,以免发生任务数大于最大线程数时,没办法把任务放到队列中也没有足够线程来执行任务的情况

③ 第三种阻塞队列是DelayedWorkQueue;对应的线程池分别是 newScheduledThreadPool (int n)和 newSingleThreadScheduledExecutor()

这两种线程池的最大特点就是可以延迟执行任务,比如说一定时间后执行任务或是每隔一定的时间执行一次任务

DelayedWorkQueue 的特点是内部元素并不是按照放入的时间排序,而是会按照延迟的时间长短对任务进行排序,内部采用的是“堆”的数据结构(堆的应用之一:优先级队列);之所以线程池 ScheduledThreadPool 和 SingleThreadScheduledExecutor 选择 DelayedWorkQueue,是因为它们本身正是基于时间执行任务的,而延迟队列正好可以把任务按时间进行排序,方便任务的执行