并发、多线程基础知识整理

目录:


image.png

1、Java并发机制底层实现原理

1.1、volatile

volatile通过一条lock汇编指令:

  1. 将当前处理器缓存行数据写回到系统内存

  2. 写回操作会使其他CPU缓存的该地址的数据无效

1.2、synchronized

1、作为锁的对象:

  1. 对于普通实例方法,锁是当前实例对象。

  2. 对于静态同步方法,锁是当前类的Cass对象。与实例方法不冲突,因为锁的对象不同;

  3. 对于同步方法块,锁是 Synchonized括号里配置的对象。

2、实现方式:(JDK6-)

基于进入和退出Monitor对象实现:

  1. monitorenter在编译后插入到同步代码块的开始位置

  2. monitorexit插入到方法结束处和异常处

监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

在 Java 虚拟机(HotSpot)中,Monitor 是基于 C++实现的,由ObjectMonitor实现的。每个对象中都内置了一个 ObjectMonitor对象。

另外,wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因

注意:不要synchronized(String),因为1. 字符串常量池有缓存功能,2. 用StringBuilder等生成的toString对象和同样内容的String对象可能不是同一个对象;

可以将synchronized (keyId)改成synchronized (keyId.intern())即可

3、优化(重要)

3.1、自旋,自适应自旋

引入原因:互斥同步对性能最大的影响是阻塞,线程的挂起和恢复--需要操作系统转入内核态才能完成,开销大;

解决办法:如果等待的锁持续时间不长,将线程挂起、恢复不值得;转而让请求锁的线程稍等一会,不放弃处理器执行时间。——需要忙等待(自旋)

自适应优化(JDK6):

  1. 当锁被占用时间很长,自旋等待时间也会很长,浪费处理器资源;——等待时间需要限制

  2. 自旋时间由前一次在同一个锁上的自旋时间及锁的拥有者的状态决定——原来获得锁容易,就认为现在也容易,就多等一会;原来就等很久,这次直接不等了;

3.2、锁消除

JVM对被检测到不可能存在共享数据竞争的锁进行消除。

判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到, 那就可以把它们当作栈上数据对待, 认为它们是线程私有的, 同步加锁自然就无须再进行。

3.3、锁粗化

如果一系列的连续操作都对同一个对象反复加锁和解锁, 甚至加锁操作是出现在循环体之中的, 那即使没有线程竞争, 频繁地进行互斥同步操作也会导致不必要的性能损耗。

虚拟机探测到有一串零碎的操作都对同一个对象加锁, 将会把加锁同步的范围扩展(粗化) 到整个操作序列的外部

3.4、轻量级锁

轻量级锁 设计的初衷是在没有多线程竞争的前提下, 减少传统的重量级锁使用操作系统互斥量产生的性能消耗

做法:通过CAS操作给锁对象加锁、解锁;如果顺利(没有遇到竞争),就可以避免重量级锁;

基于“对于绝大部分的锁, 在整个同步周期内都是不存在竞争的”这一经验法则。

如果没有竞争, 轻量级锁便通过CAS操作成功避免了使用互斥量的开销;

3.5、偏向锁

轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,

那偏向锁就是在无竞争的情况下把整个同步都消除掉, 连CAS操作都不去做了。

做法:当锁对象第一次被线程获取的时候, 虚拟机将会把对象头中的标志位设置为“01”、 把偏向模式设置为“1”, 表示进入偏向模式。 同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中。 如果CAS操作成功, 持有偏向锁的线程以后每次进入这个锁相关的同步块时, 虚拟机都可以不再进行任何同步操作(例如加锁、 解锁及对Mark Word的更新操作等)。

一旦出现另外一个线程去尝试获取这个锁的情况, 偏向模式就马上宣告结束。 根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”) , 撤销后标志位恢复到未锁定(标志位为“01”) 或轻量级锁定(标志位为“00”) 的状态, 后续的同步操作就按照上面介绍的轻量级锁那样去执行。

1.3、Volatile和Synchronized对比

  • synchronized修饰非静态方法,是对调用该方法的对象加锁

  • Synchronized修饰静态方法,是对加锁(因为类会调用它)

两个对比:

  • volatile是轻量级实现,性能比Synchronized好,只能修饰变量,而Synchronized可以修饰方法和代码块

  • 多线程访问volatile不会阻塞,Synchronized会阻塞

  • volatile保证数据可见性,有序性,不保证原子性;Synchronized保证原子性,也可间接保证可见性(会将私有内存和公共内存中的数据做同步),可见性(Synchronized保证三个性质)

  • volatile解决变量在多线程之间的可见性,Synchronized 多线程之间资源同步问题。

Q:synchronized 和 ReentrantLock 的区别

1. 两者都是可重入锁

“可重入锁” 指的是自己可以再次获取自己的内部锁。

比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增 1,所以要等到锁的计数器下降为 0 时才能释放锁。

比如,方法A,B都需要一个锁,在方法A中调用方法B,那么因为不可重入,方法B会无法执行,就陷入死锁;

2.synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API

synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。

3.ReentrantLock 比 synchronized 增加了一些高级功能

相比synchronizedReentrantLock增加了一些高级功能。主要来说主要有三点:

  • 等待可中断 : ReentrantLock提供了一种能够中断等待锁的线程的机制,通过 lock.lockInterruptibly() 来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。

  • 可实现公平锁 : ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。

  • 可实现选择性通知(锁可以绑定多个条件): synchronized关键字与wait()notify()/notifyAll()方法相结合可以实现等待/通知机制。ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition()方法。

Condition是 JDK1.5 之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是 Condition 接口默认提供的。而synchronized关键字就相当于整个 Lock 对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而Condition实例的signalAll()方法 只会唤醒注册在该Condition实例中的所有等待线程。

1.4、Lock

[图片上传失败...(image-d0cb89-1614442054849)]

2、锁

1、悲观锁

独占锁,导致所有其他需要此锁的线程挂起,导致的问题有:

  1. 加多线程竞争下,加锁和释放锁会导致较多的上下文切换,引起性能问题。
  1. 多线程可以导致死锁的问题。
  1. 多线程持有的锁会导致其他需要此锁的线程挂起。

2、乐观锁

不加锁,通过CAS(Compare And Swap)确保变量没有被别的线程修改过

CAS有三个操作参数:内存地址,期望值,要修改的新值,当期望值和内存当中的值进行比较不相等的时候,表示内存中的值已经被别线程改动过,这时候失败返回,只有相等时,才会将内存中的值改为新的值,并返回成功。

在JVM中的CAS操作就是基于处理器的CMPXCHG汇编指令实现的,因此,JVM中的CAS的原子性是处理器保障的。

可以用java代码描述为下面的形式:

public boolean compareAndSwap(int value, int expect, int update) {
//        如果内存中的值value和期望值expect一样 则将值更新为新值update
    if (value == expect) {
        value = update;
        return true;
    } else {
        return false;
    }
}

2.1、乐观锁的缺点

2.1.1、ABA问题

简单讲,线程1操作变量A之前,A被修改过多次(A->B ->A),但是线程1不知道;

例如有2个线程同时对同一个值(初始值为A)进行CAS操作,这三个线程如下

  1. 线程1,期望值为A,欲更新的值为B
  1. 线程2,期望值为A,欲更新的值为B

线程1抢先获得CPU时间片,而线程2因为其他原因阻塞了,线程1取值与期望的A值比较,发现相等然后将值更新为B,然后这个时候出现了线程3,期望值为B,欲更新的值为A,线程3取值与期望的值B比较,发现相等则将值更新为A,此时线程2从阻塞中恢复,并且获得了CPU时间片,这时候线程2取值与期望的值A比较,发现相等则将值更新为B,虽然线程2也完成了操作,但是线程2并不知道值已经经过了A->B->A的变化过程。

ABA问题的危害:

某人取钱,取款机有问题导致有两个线程欲100->50,A更新后是50,B阻塞,此时C线程汇款50,又变成100,B线程就会再次100->50,导致钱少了50;

解决办法:在每次更新时给变量加上版本号;(实际业务中,ABA问题一般不会对结果造成影响)

ABA问题在业务上的理解(有自动GC机制的语言中的问题)

假设有一个单向链表实现的栈, A->B,需要用CAS把栈顶替换为Bhead.compareAndSet(A,B);

在T1执行指令前,T2介入,popAB,push D C A,此时栈里已经没有B了,

但T1执行后,还是把B设为栈顶,栈就变成了只有B的单元素栈,D C均丢失。

本质上是物理上的同一个A(引用)在不同的时间点上可能是两个业务状态(注意引用没变,内部的状态可能变了), 前面的业务状态生效并不能代表新的业务状态的生效

2.1.2、循环时间长

CAS操作失败会一直循环,长时间不成功会大量占据cpu

解决方法: 限制自旋次数,防止进入死循环。

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

CAS的原子操作只能针对一个共享变量。

解决方法: 如果需要对多个共享变量进行操作,可以使用加锁方式(悲观锁)保证原子性,或者可以把多个共享变量合并成一个共享变量进行CAS操作。

Java利用CAS的乐观锁、原子性的特性高效解决了多线程的安全性问题,例如JDK1.8中的集合类ConcurrentHashMap、关键字volatileReentrantLock等。

3、多线程

3.1、进程与线程区别

进程——资源分配的基本单位

一个在内存中运行的应用程序。每个进程都有自己独立的一块内存空间,是系统进行资源分配和调度的独立单位

进程控制块 (Process Control Block, PCB) 描述进程的基本信息和运行状态,所谓的创建进程和撤销进程,都是指对 PCB 的操作

线程——任务调度和执行的基本单位

进程中的一个执行任务(控制单元),它是比进程更小的能独立运行的基本单位.线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源.

区别
  1. 根本区别就是 资源分配基本单位和任务调度(cpu调度)基本单位的区别

  2. 从拥有的资源:

    • 进程有独立的地址空间,进程中的堆,是一个进程中最大的一块内存,被内部所有线程共享;

    • 线程没有单独的地址空间,所使用的资源来自所属进程;只拥有在运行中必不可少的资源

      • 线程里的程序计数器就是为了记录该线程让出 CPU 时候的执行地址,待再次分配到时间片时候就可以从自己私有的计数器指定地址继续执行

      • 每个线程有自己的栈资源,用于存储该线程的局部变量和调用栈帧,其它线程无权访问

  3. 从鲁棒性:

    • 一个进程崩溃后,在保护模式下不会对其它进程产生影响

    • 一个线程死掉就等于整个进程死掉,所以多进程的程序要比多线程的程序健壮(而且线程不利于资源的管理和保护)

  4. 包含关系:

    • 一个进程至少有一个线程,一个进程可以运行多个线程,这些线程可共享数据。
  5. 执行过程:

    • 每个独立的进程有程序运行的入口、顺序执行序列和程序出口。

    • 在多线程的OS中,进程不是可执行的实体,即一个进程最少要有一个线程执行代码

    • 但是线程不能独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制

    • 两者均可并发执行

  6. 通信方式:

    • 进程间以IPC(管道、信号量、共享内存、消息队列、文件、套接字等)方式通信;

    • 同一个进程内,线程可以共享全局变量和静态变量进行通信,做到同步和互斥即可;

  7. 从切换开销说:

    • 进程切换时,(因为有独立代码和数据空间)耗费资源较大,效率要差一些。

    • 每个线程都有自己独立的运行栈和程序计数器(PC),切换时开销小

  8. 可维护性:

    • 线程可维护性比进程差,bug难排查

3.2、线程间通信

3.2.1、等待-通知

//等待方:
 synchronized(lock){
  while(falg){
  lock.wait()
  }
  //处理逻辑
 }
 
 //通知方:
 synchronized(lock){
  flag = false;
  lock.notifyAll();
 }

常考面试题:多个线程顺序打印

可参考:https://www.cnblogs.com/lazyegg/p/13900847.html

  1. 三个线程分别打印 A,B,C,要求这三个线程一起运行,打印 n 次,输出形如“ABCABCABC....”的字符串

多个线程竞争状态,要用一个共享的、可见的变量来限制执行;

比较好理解的实现:

//会浪费大量的循环时间
 public class PrintNums {
  private volatile int pos = 1;
  private volatile int count = 0;//用来计数,每次轮换到自己可执行的时候 做了多少次无用循环
 
  public void print(int i) {
  synchronized (this) {
  if (pos == i) {
  System.out.println("T-" + i + " " + count);
  pos = i % 3 + 1;
  count = 0;
  } else {
  count++;
  }
  }
  }
 
  public static void main(String[] args) {
  PrintNums demo = new PrintNums();
  for (int i = 1; i <=3; i++) {
  int j = i;
  //用匿名内部类 新建线程  调用父类的synchronized方法
  new Thread(()->{
  while(true) {
  demo.print(j);
  }
  }).start();
  }
  }
 }
 //有的count甚至达到了10^6,理解:比如当前pos = 1;但是时间片分给了t2,t2在这个时间内可能已经做了100000次循环,然后时间片给t1了;t1拿到时间片,满足条件,输出顺序要求的“1”,并输出自己等待时间片过程的努力:count == 100000 是t2和t3死循环尝试的结果

3.3、线程安全

3.3.1、含义

多个线程同时访问同一个对象,不考虑这些线程在运行环境下的调度和交替执行,不需要额外的协调同步操作,调用该对象的行为始终能获得正确的结果,那么这个对象就是线程安全的。

3.3.2、解决线程安全问题

很多时候,我们判断是否要处理线程安全问题,就看有没有多个线程同时访问一个共享变量。 像 SpringMVC这种,我们日常开发时不涉及到操作同一个成员变量,那我们就很少需要考虑线程安全问题。

  1. 能不能保证操作的原子性,考虑 atomic包下的类够不够我们使用
  1. 能不能保证操作的可见性,考虑 volatile关键字够不够我们使用
  1. 如果涉及到对线程的控制(比如一次能使用多少个线程,当前线程触发的条件是否依赖其他线程的结果),考虑CountDownLatch/Semaphore等等
  1. 如果是集合,考虑 Java.util.concurrent包下的集合类。
  1. 如果synchronized无法满足,考虑lock包下的类

3.4、死锁

避免死锁的方式一般有以下方案:

  1. 固定加锁的顺序,比如我们可以使用Hash值的大小来确定加锁的先后 ——破坏循环等待条件
  1. 尽可能缩减加锁的范围,等到操作共享变量的时候才加锁。
  1. 使用可释放的定时锁(一段时间申请不到锁的权限了,直接释放掉)——破坏不可抢占条件
  1. 分配资源时直接全部分配;

3.5、run()和start()区别

.run只是调用方法,不是启动新线程

public class HelloSogou{
  public static synchronized void main(String[] a){
  Thread t = new Thread(){
  public void run(){
  Sogou();
  }
  };
  t.run();
  //t.start();
  System.out.print("Hello");
  }
  static synchronized void Sogou(){
  System.out.print("Sogou");
  }
 }
 // SogouHello
 // t.start()的结果就是 HelloSogou

synchronized修饰静态方法的时候,锁的是整个类,对于此例就是锁住了HelloSogou.class

main首先获得了锁,因为main有锁,所以main有权调用同样需要HelloSogou.class锁的Sogou函数t.run(),输出Sogou之后,继续输出Hello;

如果是开启一个新的线程(t.start()):

  1. main线程持有锁,main需要先执行,输出Hello

  2. t线程获得HelloSogou.class锁,开始执行,输出Sogou

4、并发的三大特性(要考虑的问题)

1. 原子性

原子性:一个或多个指令在 CPU 执行的过程中不被中断的特性

操作系统对当前执行线程的切换,带来了原子性问题

JMM定义了8种原子性的操作:

lock(锁定):作用于主内存中的变量,它把一个变量标识为一个线程独占的状态;

unlock(解锁): 作用于主内存中的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定

read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便后面的load动作使用;

load(载入):作用于工作内存中的变量,它把read操作从主内存中得到的变量值放入工作内存中的变量副本

use(使用):作用于工作内存中的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作;

assign(赋值):作用于工作内存中的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作;

store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送给主内存中以便随后的write操作使用;

write(操作):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

Synchronized就是利用了lockunlock两个操作实现的原子性

2. 有序性

编译器指令重排优化,带来了有序性问题

Synchronized会保证结果是串行化执行的结果,但是可能重排

volatile是禁止重排;

3. 可见性

可见性的意思:一个线程修改了共享变量后,其他线程能够立即得知这个修改。

CPU 缓存,在多核 CPU 的情况下,带来了可见性问题

进程中的内存分为工作内存(线程内存)和主内存;普通变量的读写依赖于当前工作内存,直到线程结束,才会把值更新到主内存。 volatile修饰的变量每次获取的值都是从主內存中直接读的,写完之后也会直接更新到主內存

volatile的实现方式:

  1. 用Lock指令锁定这块内存区域的缓存并写到内存,

  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存失效

synchronized的实现方式:

借助JVM指令monitor entermonitor exit,通过排他机制使线程串行通过同步块,monitor退出后共享内存会被刷新到主内存。

缓存一致性协议

每个处理器嗅探总线上传播的数据来检查自己缓存值是否过期,如发现缓存对应内存地址被修改,就设缓存为无效状态。

5、并发容器和框架

5.1、Map

HashMap和TreeMap是线程不安全的,Colleations提供了synchronizedxxx()方法,可以将指定的集合包装成线程同步的集合。但是效率不高,推荐使用ConcurrentHashMap

5.2、Collable

与Runnable的功能大致相似,区别如下:

  • Runnable 接口 run 方法无返回值;Callable 接口 call 方法有返回值,返回值就是传递进来的泛型

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

5.3、ThreadLocal类

变量值的共享可以使用public static变量的形式,所有的线程都使用同一个public static变量。如果想实现每一个线程都有自己的共享变量,那就可以使用ThreadLocal类。

类ThreadLocal主要解决的就是每个线程绑定自己的值,可以将ThreadLocal类比喻成全局存放数据的盒子,盒子中可以存储每个线程的私有数据。

优秀博客:https://www.jianshu.com/p/807686414c11

5.4 相似的数据结构

HashMap和HashTable

  • 区别1: HashMap可以存放 null HashTable不能存放null

  • 区别2: HashMap不是线程安全的类 Hashtable是线程安全的类

StringBuilder和StringBuffer

StringBuffer 是线程安全的 StringBuilder 是非线程安全的

ArrayList和Vector

Vector是线程安全的类,ArrayList是非线程安全的。

二者的类的声明完全相同

5.5 转换为线程安全的类

HashSet,LinkedList,HashMap等等非线程安全的类,都可以通过工具类Collections转换为线程安全的

 List list1 = new ArrayList<>();
  List list2 = Collections.synchronizedList(list1);

5.5、阻塞队列

BlockingQueue

5.5.1、结构

[图片上传失败...(image-17ba70-1614442054839)]

队列+等待可用操作(取时等待非空,放时等待不满)

AbstractQueue是一个可自然排序的优先队列

5.5.2、使用

四组API

作用 抛出异常的方法 无异常,有返回值的 阻塞等待 超时等待
添加 add offer(返回boolean) put offer(object,timeout,TimeUnit)
移除 remove poll(取不到返回null) take poll(timeout,TimeUnit)
判断队列首 element() peek() -

6、线程池

public ThreadPoolExecutor(int corePoolSize,
  int maximumPoolSize,
  long keepAliveTime,
  TimeUnit unit,
  BlockingQueue workQueue,
  ThreadFactory threadFactory,
  RejectedExecutionHandler handler) {

corePoolSize: 基本大小,没有任务也是这个大小;

后面的线程就放到队列里,队列满了之后,才开始新建比coreSize更多的线程;

maximumPoolSize: 线程池最大数量;

保活时间选择:任务多&任务执行时间短:调大时间,提高线程利用率;

当线程池中的线程数量大于 corePoolSize 的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了 keepAliveTime才会被回收销毁;

getPoolSize()方法:返回线程池中的当前的线程数量;(有关误区发在下一篇文章上)

6.1、Runnable接口和Callable接口区别

Runnable自 Java 1.0 以来一直存在,但Callable仅在 Java 1.5 中引入,目的就是为了来处理Runnable不支持的用例。Runnable 接口不会返回结果或抛出检查异常,但是Callable 接口可以。所以,如果任务不需要返回结果或抛出异常推荐使用 Runnable 接口,这样代码看起来会更加简洁。

工具类 Executors 可以实现 Runnable 对象和 Callable 对象之间的相互转换。(Executors.callable(Runnable task)或 Executors.callable(Runnable task,Object resule))。

6.2、提交任务

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

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

6.3、关闭线程池

shutdown(): 常用,将状态设为SHUTDOWN,中断所有没有正在执行任务的线程;

shutdownNow(): 先将状态设为STOP,再尝试停止所有线程;并返回等待执行任务的列表(如果任务不一定要执行完,可以调用这个

6.4、合理配置

  • CPU密集型:尽量少,如

  • IO密集型:尽量多,如

  • 如依赖其他系统资源(如数据库连接),因为提交SQL后需要等待数据库返回结果,因此CPU空闲时间可能较长,应该把线程数设大,提高利用率;

  • 建议用有界队列

问题

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

线程池除了初始大小和池子最大值,还多了一个阻塞队列来缓冲。数据源连接池一般请求的连接数超过连接池的最大值的时候就会触发拒绝策略,策略一般是阻塞等待设置的时间或者直接抛异常。而线程池的触发时机如下图:

image.png

如图,想要了解线程池什么时候触发拒绝粗略,需要明确上面三个参数的具体含义,是这三个参数总体协调的结果,而不是简单的超过最大线程数就会触发线程拒绝粗略,

当提交的任务数大于corePoolSize时,会优先放到队列缓冲区,只有填满了缓冲区后,才会判断当前运行的任务是否大于maxPoolSize,小于时会新建线程处理。大于时就触发了拒绝策略;

总结就是:当前提交任务数大于(maxPoolSize + queueCapacity)时就会触发线程池的拒绝策略了。

常见问题

1、进程与线程

Q:为什么要有线程?

每个进程都有自己的地址空间,一个服务器通常需要接收大量并发请求,为每一个请求都创建一个进程系统开销大、请求响应效率低;因此引入线程。

Q:为什么要多线程(并发编程)

  1. 提高系统资源利用率:
*   现在CPU往往都是多核的,如果不用多线程,CPU的利用率就很低
    
    
*   CPU的运算、IO速度与硬盘的IO速度差距很大,多线程可以在IO繁忙而CPU空闲时充分利用CPU的算力
  1. 并发程序可以更好地处理复杂业务,对复杂业务进行多任务拆分,简化任务调度,同步执行任务。

Q:进程线程的选择?

  • 需要频繁创建销毁的优先使用线程。因为进程创建、销毁一个进程代价浪大,需要不停的分配资源线程频繁的调用只改变CPU的执行
  • 线程的切换速度快,需要大量计算,切换频繁时,用线程
  • 耗时的操作使用线程可提高应用程序的响应
  • 线程对CPU的使用效率更优,多机器分布的用进程,多核分布用线程
  • 需要跨机器移植,优先考虑用进程
  • 需要更稳定、安全时,优先考虑用进程
  • 需要速度时,优先考虑用线程
  • 并行性要求很高时,优先考虑用线程

2、实际使用

CyclicBarrierCountDownlatch都可以让一组线程等待其他线程 (前者是让一组线程相互等待到某一个状态再执行。后者是一个线程等待其他线程结束再执行。)

Q:Thread类中有哪些属性?

tid(long)

name

group 线程组

daemon(boolean = false) 是否守护线程

priority

Java 线程优先级使用 1 ~ 10 的整数表示:

  • 最低优先级 1:Thread.MIN_PRIORITY
  • 最高优先级 10:Thread.MAX_PRIORITY
  • 普通优先级 5:Thread.NORM_PRIORITY

Q:哪种变量声明方式可以避免程序在多线程竞争下读到不正确的值

volatile

static volatile

注意:synchronized不能修饰变量,修饰方法或代码块或对象;

Q:哪些存储键值对的实现类的方法调用提供了多线程安全支持?

java.util.ConcurrentHashMap java.util.HashTable

其他的,TreeMap SortMap都不支持;

Q:哪些操作会使线程释放锁资源?

wait()

join()

所谓的释放锁资源实际是通知对象内置的monitor对象进行释放,而只有所有对象都有内置的monitor对象才能实现任何对象的锁资源都可以释放。又因为所有类都继承自Object,所以wait()就成了Object方法,也就是通过wait()来通知对象内置的monitor对象释放,而且事实上因为这涉及对硬件底层的操作,所以wait()方法是native方法,底层是用C写的。

其他都是Thread所有,是没有资格释放资源的,而join()有资格释放资源其实是通过调用wait()来实现的

yield()不会释放锁,只是通知调度器自己可以让出cpu时间片,但只是建议,调度器也不一定采纳

Q:sleep()wait()有什么区别

共同点:两者都可以暂停线程的执行。

  • 两者最主要的区别在于:sleep() 方法没有释放锁,而 wait() 方法释放了锁

  • sleep()方法可以在任何地方使用;wait()方法则只能在同步方法或同步块中使用;

  • wait() 通常被用于线程间交互/通信,sleep()通常被用于暂停执行。

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

  • sleep()是线程线程类(Thread)的方法;wait()是Object的方法,调用会放弃对象锁,进入等待队列,待调用notify()/notifyAll()唤醒指定的线程或者所有线程,才会进入锁池,再次获得对象锁才会进入运行状态;

Reference

  1. 《深入理解Java虚拟机 JVM高级特性与最佳实践》

  2. 《Java并发编程的艺术》

  3. 《现代操作系统》

  4. 微信公众号“面试造火箭”https://mp.weixin.qq.com/s?__biz=MzU4NzA3MTc5Mg==&mid=2247483918&idx=1&sn=ab8550bb284edcf7cf0c6d0b41e0c2f6&chksm=fdf0ea51ca8763471470e9957eecfb33390b4efbcfd182429538c5b8c267d6e7e91b20a5a749&mpshare=1&scene=23&srcid=0221a3rEWqGx8CEZH5vS8xqH&sharer_sharetime=1613909573104&sharer_shareid=1f93d6ffaa1ed36a81d21d3bfe4e306e#rd

  5. https://www.javanav.com/interview/

你可能感兴趣的:(并发、多线程基础知识整理)