多线程-面试题

目录

线程和进程有什么区别

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

为什么要使用多线程呢

线程的状态流转

并行和并发有什么区别

多线程的优劣

什么是上下文切换

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

什么是线程调度器(Thread Scheduler)和时间分片(Time Slicing )

sleep()、wait()、join()、yield()

什么是线程死锁,如何避免线程死锁

并发编程三要素/线程安全问题表现

在 Java 程序中怎么保证多线程的运行安全

execute() vs submit()区别

sleep() 和 wait() 有什么区别

如果你提交任务时,线程池队列已满,这时会发生什么

一个线程运行时发生异常会怎样

synchronized、volatile、CAS 比较

synchronized 和 ReentrantLock 区别是什么

谈谈volatile的使用及其原理

synchronized 关键字和 volatile 关键字的区别

ThreadLocal是什么?有什么用

乐观锁和悲观锁的理解及如何实现,有哪些实现方式

什么是 CAS

CAS 的会产生什么问题

ReadWriteLock 是什么

CopyOnWriteArrayList是什么

对线程安全的理解

为什么用线程池,解释一下线程池的参数

线程池中阻塞队列的作用,为什么是先添加队列而不是先创建最大线程

线程池中线程复用原理

如何实现接口的幂等性


线程和进程有什么区别

根本区别进程是操作系统资源分配的基本单位,操作系统上运行的一个程序,而线程是处理器任务调度和执行的基本单位,进程中一条执行路径

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

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

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

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

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

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

1)采用实现Runnable、Callable接口的方式创建多线程。

优势是

线程类只是实现了Runnable接口或Callable接口,还可以继承其他类

在这种方式下,多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。

劣势是:

编程稍微复杂,如果要访问当前线程,则必须使用Thread.currentThread()方法。

2)使用继承Thread类的方式创建多线程

优势是:

编写简单,如果需要访问当前线程,则无需使用Thread.currentThread()方法,直接使用this即可获得当前线程。

劣势是:

线程类已经继承了Thread类,所以不能再继承其他父类。

3)Runnable和Callable的区别

  • Callable规定(重写)的方法是call(),Runnable规定(重写)的方法是run()。
  • Callable的任务执行后可返回值,而Runnable的任务是不能返回值的。
  • Call方法可以抛出异常,run方法不可以。
  • 运行Callable任务可以拿到一个Future对象,表示异步计算的结果。它提供了检查计算是否完成的方法,以等待计算的完成,并检索计算的结果。通过Future对象可以了解任务执行情况,可取消任务的执行,还可获取执行结果。

4)使用 Executors 工具类创建线程池

  • 提高响应速度(减少了创建新线程的时间)
  • 降低资源消耗(重复利用线程池中线程,不需要每次都创建)
  • 便于线程管理

为什么要使用多线程呢

  • 从计算机底层来说: 线程可以比作是轻量级的进程,是程序执行的最小单位,线程间的切换和调度的成本远远小于进程。另外,多核 CPU 时代意味着多个线程可以同时运行,这减少了线程上下文切换的开销
  • 从当代互联网发展趋势来说: 现在的系统动不动就要求百万级甚至千万级的并发量,而多线程并发编程正是开发高并发系统的基础,利用好多线程机制可以大大提高系统整体的并发能力以及性能。

从计算机底层来说:

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

并发编程缺点

  • 并发编程可能会遇到很多问题,比如:内存泄漏、上下文切换、线程安全、死锁等问题

线程的状态流转

  • 线程的生命周期及五种基本状态:

Java线程具有五中基本状态

1)新建状态(New)当线程对象对创建后,即进入了新建状态,如:Thread t = new MyThread();

2)就绪状态(Runnable):当调用线程对象的start()方法(t.start();),线程即进入就绪状态。处于就绪状态的线程,只是说明此线程已经做好了准备,随时等待CPU调度执行,并不是说执行了t.start()此线程立即就会执行;

3)运行状态(Running):当CPU开始调度处于就绪状态的线程时,此时线程才得以真正执行,即进入到运行状态。注:就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中;

4)阻塞状态(Blocked):处于运行状态中的线程由于某种原因,暂时放弃对CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才有机会再次被CPU调用以进入到运行状态。根据阻塞产生的原因不同,阻塞状态又可以分为三种:

1.等待阻塞:运行状态中的线程执行wait()方法,使本线程进入到等待阻塞状态;

2.同步阻塞 — 线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态;

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

5)死亡状态(Dead):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

并行和并发有什么区别

  • 并发:多个任务在同一个 CPU 核上,按细分的时间片轮流(交替)执行,从逻辑上来看那些任务是同时执行。
  • 并行:单位时间内,多个处理器或多核处理器同时处理多个任务,是真正意义上的“同时进行”。
  • 串行:有n个任务,由一个线程按顺序执行。由于任务、方法都在一个线程执行所以不存在线程不安全情况,也就不存在临界区的问题

多线程的优劣

  • 多线程的好处
    • 可以提高 CPU 的利用率。在多线程程序中,一个线程必须等待的时候,CPU 可以运行其它的线程而不是等待,这样就大大提高了程序的效率。也就是说允许单个程序创建多个并行执行的线程来完成各自的任务
  • 多线程的劣势
    • 线程也是程序,所以线程需要占用内存,线程越多占用内存也越多;
    • 多线程需要协调和管理,所以需要 CPU 时间跟踪线程;
    • 线程之间对共享资源的访问会相互影响,必须解决竞用共享资源的问题

什么是上下文切换

  • 多线程编程中一般线程的个数都大于 CPU 核心的个数,而一个 CPU 核心在任意时刻只能被一个线程使用,为了让这些线程都能得到有效执行,CPU 采取的策略是为每个线程分配时间片并轮转的形式。当一个线程的时间片用完的时候就会重新处于就绪状态让给其他线程使用,这个过程就属于一次上下文切换。
  • 概括来说就是:当前任务在执行完 CPU 时间片切换到另一个任务之前会先保存自己的状态,以便下次再切换回这个任务时,可以再加载这个任务的状态。任务从保存到再加载的过程就是一次上下文切换

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

  • 计算机通常只有一个 CPU,在任意时刻只能执行一条机器指令,每个线程只有获得CPU 的使用权才能执行指令。所谓多线程的并发运行,其实是指从宏观上看,各个线程轮流获得 CPU 的使用权,分别执行各自的任务。在运行池中,会有多个处于就绪状态的线程在等待 CPU,JAVA 虚拟机的一项任务就是负责线程的调度,线程调度是指按照特定机制为多个线程分配 CPU 的使用权
  • 有两种调度模型:分时调度模型和抢占式调度模型
    • 分时调度模型是指让所有的线程轮流获得 cpu 的使用权,并且平均分配每个线程占用的 CPU 的时间片这个也比较好理解。
    • Java虚拟机采用抢占式调度模型,是指优先让可运行池中优先级高的线程占用CPU,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用CPU。处于运行状态的线程会一直运行,直至它不得不放弃 CPU

什么是线程调度器(Thread Scheduler)和时间分片(Time Slicing )

  • 线程调度器是一个操作系统服务,它负责为 Runnable 状态的线程分配 CPU 时间
  • 时间分片是指将可用的 CPU 时间分配给可用的 Runnable 线程的过程。分配 CPU 时间可以基于线程优先级或者线程等待的时间
  • 线程调度并不受到 Java 虚拟机控制,所以由应用程序来控制它是更好的选择(也就是说不要让你的程序依赖于线程的优先级)

sleep()、wait()、join()、yield()

  • yield方法执行后,线程直接进入就绪状态
  • join()方法执行后线程进入阻塞状态,例如在线程B中调用A的join()方法,那线程B会进入到阻塞队列,直到线程A结束或中断线程

什么是线程死锁,如何避免线程死锁

  • 不同的线程分别占用对方需要的同步资源不放弃,都在等待对方放弃自己需要的同步资源,就形成了线程的死锁
  • 尽量使用 tryLock(long timeout, TimeUnit unit)的方法(ReentrantLock、ReentrantReadWriteLock),设置超时时间,超时可以退出防止死锁
  • 尽量使用 Java. util. concurrent 并发类代替自己手写锁
  • 尽量降低锁的使用粒度,尽量不要几个功能用同一把锁
  • 尽量减少同步的代码块

并发编程三要素/线程安全问题表现

  • 原子性:原子,即一个不可再被分割的颗粒。原子性指的是一个或多个操作要么全部执行成功要么全部执行失败,操作全部执行完,才会进行线程操作

    • 比如i++操作,分为3步,第一步就是从主存中都数据到线程的工作内存中,然后进行+1操作,最后将数据返回给主存中去,两个线程同时操作的话,有可能在一个线程操作的过程中,cpu进行线程切换,另一个线程开始工作,最终造成主存中的数据是不正确的
    • 如果是原子性的话,操作全部执行完之后,才会进行线程切换
  • 可见性:一个线程对共享变量的修改,另一个线程能够立刻看到。(synchronized,volatile)
    • 单单保证原子性也不能保证i++的正确,也需要保证可见性,将工作内存的值刷回主存,加上这一步能够保证数据的正确性,那个astomic里面的方法,volatile和cas保证线程安全
  • 有序性:程序执行的顺序按照代码的先后顺序执行。(处理器可能会对指令进行重排序)
    • 虚拟机在进行代码编译时,对于那些改变顺序之后不会对最终结果造成影响的代码,虚拟机不一定会按照我们写的代码的顺序来执行,有可能将他们重排序,实际上对于有些代码进行重排序之后,虽然对变量的值没有造成影响,但有可能出现线程安全问题

在 Java 程序中怎么保证多线程的运行安全

  • 出现线程安全问题的原因:

    • 线程切换带来的原子性问题
    • 缓存导致的可见性问题
    • 编译优化带来的有序性问题
  • 解决办法:
    • JDK Atomic开头的原子类、synchronized、LOCK,可以解决原子性问题
    • synchronized、volatile、LOCK,可以解决可见性问题
    • Happens-Before 规则可以解决有序性问题

execute() vs submit()区别

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

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

sleep() 和 wait() 有什么区别

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

  • 类的不同:sleep() 是 Thread线程类的静态方法,wait() 是 Object类的方法。
  • 是否释放锁:sleep() 不释放锁;wait() 释放锁。
  • 用途不同:Wait 通常被用于线程间交互/通信,sleep 通常被用于暂停执行。
  • 用法不同:wait() 方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify() 或者 notifyAll() 方法。sleep() 方法执行完成后,线程会自动苏醒。或者可以使用wait(long timeout)超时后线程会自动苏醒
  • wait方法依赖于synchronized关键字

如果你提交任务时,线程池队列已满,这时会发生什么

  • 如果使用的是无界队列 LinkedBlockingQueue,也就是无界队列的话,没关系,继续添加任务到阻塞队列中等待执行,因为 LinkedBlockingQueue 可以近乎认为是一个无穷大的队列,可以无限存放任务

  • 如果使用的是有界队列比如 ArrayBlockingQueue,任务首先会被添加到ArrayBlockingQueue 中,ArrayBlockingQueue 满了,会根据maximumPoolSize 的值增加线程数量,如果增加了线程数量还是处理不过来,ArrayBlockingQueue 继续满,那么则会使用拒绝策略RejectedExecutionHandler 处理满了的任务,默认是 AbortPolicy

一个线程运行时发生异常会怎样

如果异常没有被捕获,该线程将会停止执行。Thread.UncaughtExceptionHandler是用于处理未捕获异常造成线程突然中断情况的一个内嵌接口。当一个未捕获异常将造成线程中断的时候,JVM 会使用 Thread.getUncaughtExceptionHandler()来查询线程的 UncaughtExceptionHandler 并将线程和异常作为参数传递给 handler 的 uncaughtException()方法进行处理

锁升级的过程

为什么会有锁升级的过程呢

在java6以前synchronized锁实现都是重量级锁的形式,效率低下,为了提升效率进行了优化,所以出现了锁升级的过程

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

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

偏向锁

  • HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,所以为了让线程获得锁的代价更低而引入了偏向锁。
  • 当一个线程访问加了同步锁的代码块时,会在对象头中存储当前线程的 ID,后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的线程ID。如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了

轻量级锁

  • 轻量级锁,一般用于两个线程在交替使用锁的时候,由于没有同时抢锁,属于一种比较和谐的状态,就可以使用轻量级锁。

自旋锁

  • 轻量级锁在加锁过程中,用到了自旋锁。所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。
  • 为什么要采用自旋等待呢
  • 因为绝大多数情况下线程获得锁和释放锁的过程都是非常短暂的,自旋一定次数之后极有可能碰到获得锁的线程释放锁,所以,轻量级锁适用于那些同步代码块执行很快的场景,这样,线程原地等待很短的时间就能够获得锁了。
  • 注意:锁在原地循环等待的时候,是会消耗CPU资源的。所以自旋必须要有一定的条件控制,否则如果一个线程执行同步代码块的时间很长,那么等待锁的线程会不断的循环反而会消耗CPU资源。默认情况下锁自旋的次数是 10 次,可以使用-XX:PreBlockSpin参数来设置自旋锁等待的次数

重量级锁

  • 当轻量级锁膨胀到重量级锁之后,意味着线程只能被挂起阻塞来等待唤醒了。每一个对象中都有一个Monitor监视器,而Monitor依赖操作系统的 MutexLock(互斥锁)来实现的, 线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能。
  • monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。而且当一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。我们可以简单的理解为,在加重量级锁的时候会执行monitorenter指令,解锁时会执行monitorexit指令

锁的优缺点对比

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步代码块仅存在纳秒级差距 如果线程间存在锁竞争,会带来额外的锁撤销消耗 适用于只有一个线程访问同步代码块场景
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到锁,使用自旋会消耗CPU 追求响应时间;同步代码块执行时间非常短
重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量;同步代码块执行时间较长

synchronized、volatile、CAS 比较

  • synchronized 是悲观锁,属于抢占式,会引起其他线程阻塞

  • volatile 提供多线程共享变量可见性和禁止指令重排序优化
  • CAS 是基于冲突检测的乐观锁(非阻塞)

synchronized 和 ReentrantLock 区别是什么

  • 相同点:两者都是可重入锁,都是独占锁

    • 可重入锁:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁
    • 一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法,而无需重新获得锁
  • 主要区别:
    • ReentrantLock 必须手动获取与释放锁,而 synchronized 不需要手动释放和开启锁;
    • synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API
    • synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的
      • ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成)
    • ReentrantLock 比 synchronized 增加了一些高级功能
    • 等待可中断.通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。
    • ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。 ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。
    • ReentrantLock类线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。 在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知”

谈谈volatile的使用及其原理

  • volatile 关键字来保证可见性和禁止指令重排,确保一个线程的修改能对其他线程是可见的

  • 当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值
  • 从实践角度而言,volatile 的一个重要作用就是和 CAS 结合,保证了原子性,详细的可以参见 java.util.concurrent.atomic 包下的类,比如 AtomicInteger
  • volatile 常用于多线程环境下的单次操作(单次读或者单次写)

synchronized 关键字和 volatile 关键字的区别

  • volatile关键字是线程同步的轻量级实现,所以volatile性能肯定比synchronized关键字要好

  • volatile关键字只能用于变量而synchronized关键字可以修饰方法以及代码块。实际开发中使用 synchronized 关键字的场景还是更多一些
  • 多线程访问volatile关键字不会发生阻塞,而synchronized关键字可能会发生阻塞
  • volatile关键字能保证数据的可见性,但不能保证数据的原子性。synchronized关键字两者都能保证
  • volatile关键字主要用于解决变量在多个线程之间的可见性,而 synchronized关键字解决的是多个线程之间访问资源的同步性。
  • synchronized关键字在JavaSE1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁以及其它各种优化之后执行效率有了显著提升,实际开发中使用 synchronized 关键字的场景还是更多一些

ThreadLocal是什么?有什么用

  • 每一个Thread对象均含有一个ThreadLocaMap类型的成员变量threadlocals,它存储本线程中所有ThreadLocal对象及其对应的值,就是我们需要存的值

  • ThreadLocalMap由一个个Entry 对象构成Entry继承自WeakReference<ThreadLoca1<?>>,一个Entry由ThreadLocal对象和object构成。由此可见,Entry 的key是ThreadLocal对象,并且是一个弱引用。当没指向key的强弓用后,该key就会被垃圾收集器回收
  • 当执行set方法时,ThreadLocal首先会获取当前线程对象,然后获取当前线程的ThreadlocalMap对象。再以当前ThreadLocal对象为key,将值存储进ThreadLocalMap对象中
  • get方法执行过程类似。Threadlocal首先会获取 当前线程对象,然后获取当前线程的ThreadLocalMap对象。再以当前ThreadLocal对象为key,获取对应的value。
  • 由于每一条线程均含有各自私有的ThreadLocalMap容器,这些容器相互独立互不影响, 因此不会存在线程安全性问题,从而也无需使用同步机制来保证多条线程访问容器的互斥性。
  • 使用场景:1、在进行对象跨层传递的时候,使用ThreadLocal可以避免多次传递, 打破层次间的约束。2、线程间数据隔离

  • ThreadLocal是一个本地线程副本变量工具类。主要用于将私有线程和该线程存放的副本对象做一个映射,各个线程之间的变量互不干扰,在高并发场景下,可以实现无状态的调用,特别适用于各个线程依赖不通的变量值完成操作的场景。
  • 简单说ThreadLocal就是一种以空间换时间的做法,在每个Thread里面维护了一个以开地址法实现的ThreadLocal.ThreadLocalMap,把数据进行隔离,数据不共享,自然就没有线程安全方面的问题了

乐观锁和悲观锁的理解及如何实现,有哪些实现方式

  • 悲观锁:

    • 总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。再比如 Java 里面的同步原语 synchronized 关键字的实现也是悲观锁
  • 乐观锁:
    • 顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于 write_condition 机制,其实都是提供的乐观锁。在 Java中 java.util.concurrent.atomic 包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的
  • 乐观锁的实现方式:
    • 使用版本标识来确定读到的数据与提交时的数据是否一致。提交后修改版本标识,不一致时可以采取丢弃和再次尝试的策略。
    • java 中的 Compare and Swap 即 CAS ,当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。 CAS 操作中包含三个操作数 —— 需要读写的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B)。如果内存位置 V 的值与预期原值 A 相匹配,那么处理器会自动将该位置值更新为新值 B。否则处理器不做任何操作

什么是 CAS

  • CAS 是 compare and swap 的缩写,即我们所说的比较交换

  • CAS 是一种基于锁的操作,而且是乐观锁。在 java 中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加 version 来获取数据,性能较悲观锁有很大的提高
  • CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和 A 的值是一样的,那么就将内存里面的值更新成 B。CAS是通过无限循环来获取数据的,若果在第一轮循环中,a 线程获取地址里面的值被b 线程修改了,那么 a 线程需要自旋,到下次循环才有可能机会执行
  • java.util.concurrent.atomic 包下的类大多是使用 CAS 操作来实现的(AtomicInteger,AtomicBoolean,AtomicLong)

CAS 的会产生什么问题

  • ABA 问题

    • 比如说一个线程 one 从内存位置 V 中取出 A,这时候另一个线程 two 也从内存中取出 A,并且 two 进行了一些操作变成了 B,然后 two 又将 V 位置的数据变成 A,这时候线程 one 进行 CAS 操作发现内存中仍然是 A,然后 one 操作成功。尽管线程 one 的 CAS 操作成功,但可能存在潜藏的问题。从 Java1.5 开始 JDK 的 atomic包里提供了一个类 AtomicStampedReference 来解决 ABA 问题
  • 循环时间长开销大
    • 对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized
  • 只能保证一个共享变量的原子操作
    • 当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁

ReadWriteLock 是什么

  • 首先明确一下,不是说 ReentrantLock 不好,只是 ReentrantLock 某些时候有局限。如果使用 ReentrantLock,可能本身是为了防止线程 A 在写数据、线程 B 在读数据造成的数据不一致,但这样,如果线程 C 在读数据、线程 D 也在读数据,读数据是不会改变数据的,没有必要加锁,但是还是加锁了,降低了程序的性能。因为这个,才诞生了读写锁 ReadWriteLock

  • ReadWriteLock 是一个读写锁接口,读写锁是用来提升并发程序性能的锁分离技术,ReentrantReadWriteLock 是 ReadWriteLock 接口的一个具体实现,实现了读写的分离,读锁是共享的,写锁是独占的,读和读之间不会互斥,读和写、写和读、写和写之间才会互斥,提升了读写的性能
  • 读写锁有以下三个重要的特性
    • 公平选择性:支持非公平(默认)和公平的锁获取方式,吞吐量还是非公平优于公平。
    • 重进入:读锁和写锁都支持线程重进入。
    • 锁降级:遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁

CopyOnWriteArrayList是什么

  • CopyOnWriteArrayList 是一个并发容器。有很多人称它是线程安全的,我认为这句话不严谨,缺少一个前提条件,那就是非复合场景下操作它是线程安全的。

  • CopyOnWriteArrayList(免锁容器)的好处之一是当多个迭代器同时遍历和修改这个列表时,不会抛出 ConcurrentModificationException。在CopyOnWriteArrayList 中,写入将导致创建整个底层数组的副本,而源数组将保留在原地,使得复制的数组在被修改时,读取操作可以安全地执行
  • 通过源码分析,我们看出它的优缺点比较明显,所以使用场景也就比较明显。就是合适读多写少的场景。
  • CopyOnWriteArrayList 的缺点
    • 由于写操作的时候,需要拷贝数组,会消耗内存,如果原数组的内容比较多的情况下,可能导致 young gc 或者 full gc。
    • 不能用于实时读的场景,像拷贝数组、新增元素都需要时间,所以调用一个 set 操作后,读取到数据可能还是旧的,虽然CopyOnWriteArrayList 能做到最终一致性,但是还是没法满足实时性要求。
    • 由于实际使用中可能没法保证 CopyOnWriteArrayList 到底要放置多少数据,万一数据稍微有点多,每次 add/set 都要重新复制数组,这个代价实在太高昂了。在高性能的互联网应用中,这种操作分分钟引起故障。
  • CopyOnWriteArrayList 的设计思想
    • 读写分离,读和写分开
    • 最终一致性
    • 使用另外开辟空间的思路,来解决并发冲突

对线程安全的理解

不是线程安全、应该是内存安全,堆是共享内存,可以被所有线程访问

当多个线程访问个对象时, 如果不用进行额外的同步控制或其他的协调操作:,调用这个对象的行为都可以获得正确的结果,我们就说这个对象是线程安全的

堆是进程和线程共有的空间,分全局堆和局部堆。全局堆就是所有没有分配的空间,局部堆就是用户分配的空间。堆在操作系统对进程初始化的时候分配,运行过程中也可以向系统要额外的堆,但是用完了要还给操作系统,要不然就是内存泄漏。

在Java中,堆是ava虚拟机所管理的内存中最大的块,是所有线程共享的一块内存区域,在虚拟机启动时创建。堆所存在的内存区域的唯一目的就是存放对象实例, 几乎所有的对象实例以及数组都在这里分配内存。

栈是每个线程独有的,保存其运行状态和局部自动变量的。栈在线程开始的时候初始化,每个线程的栈互相独立,因此,栈线程安全的。操作系统在切换线程的时候会自动切换栈。栈空间不需要在高级语言里面显式的分配和释放。

目前主流操作系统都是多任务的,即多个进程同时运行。为了保证安全,每个进程只能访问分配给自己的内存空间,而不能访问别的进程的,这是由操作系统保障的。

在每个进程的内存空间中都会有一块特殊的公共区域, 通常称为堆(内存)。进程内的所有线程都可以访问到该区域,这就是造成问题的潜在原因

为什么用线程池,解释一下线程池的参数

为什么使用线程池

  • 降低资源的消耗,线程的创建以及销毁都比较消耗资源,提高线程的利用率,把线程创建好了保存到线程池中,需要用线程的时候,把线程拿出来就可以了,省掉了创建跟销毁的操作

  • 因为不用重新创建和销毁,所以也能带来响应速度的提升
  • 提高线程的管理性,不断地复用里面的资源,可以监控里面的状态以及运行参数,程序员不断new这个线程的话,监控不到也很难管理

解释线程池参数

  • corePoolsize代表核心线程数,也就是正常情况下创建工作的线程数,这些线程创建后并不会消除,而是种常驻线程
  • maxinumPoo1size代表的是最大线程数,它与核心线程数相对应,表示最大允许被创建的线程数,比如当前任务较多,将核心线程数都用完了,还无法满足需求时,此时就会创建新的线程,但是线程池内线程总数不会超过最大线程数
  • KeepAliveTime、unit 表示超出核心线程数之外的线程的空闲存活时间,也就是核心线程不会消除,但是超出核心线程数的部分线程如果空闲一定的时间则会被消除我们可以通过setKeepAliveTime来设置空闲时间
  • workQueue用来存放待执行的任务,假设我们现在核心线程都已被使用,还有任务进来则全部放入队列,直到整个队列被放满但任务还再持续进入则会开始创建新的线程
  • ThreadFactory实际上是一 个线程工厂,用来生产线程执行任务。我们可以选择使用默认的创建工厂;产生的线程都在同一个组内, 拥有相同的优先级,且都不是守护线程。当然我们也可以选择自定义线程工厂,一般我们会根据业务来制定不同的线程工厂
  • Handler任务拒绝策略,有两种情况,第种是 当我们调用shutdown等方法关闭线程池后,这时候即使线程池内部还有没执行完的任务正在执行,但是由于线程池已经关闭,我们再继续想线程池提交任务就会遭到拒绝。另一种情况就是当达到最大线程数,线程池已经没有能力继续处理新提交的任务时,这是也就拒绝

线程池处理流程

  • 创建线程池,往线程池里面添加任务,判断核心线程数有没有满,如果核心线程没有满,创建核心线程执行任务,如果核心线程数已满,判断任务队列是否为满,如果任务队列没有满,任务就放到队列里面进行等待
  • 如果任务队列里面满了,那么判断最大线程数是否达到,如果没有达到,会创建临时线程去执行这个任务,临时线程执行完这个任务,空闲下来之后,有一个keepAlive进行判断,超出这个时间会被回收
  • 如果达到的话,根据拒绝策略进行处理任务

线程池中阻塞队列的作用,为什么是先添加队列而不是先创建最大线程

  • 一般的队列只能保证作为一个有限长度的缓冲区,如果超出了缓冲长度,就无法保留当前的任务了,阻塞队列通过阻塞可以保留当前想要继续入队的任务

  • 阻塞队列可以保证任务队列中没有任务时阻塞获取任务的线程,使得线程进入wait状态,释放cpu资源
  • 阻塞队列自带阻塞和唤醒的功能,不需要额外的处理
  • 在创建新线程的时候,是要获取全局锁的,这个时候其他的就得阻塞,影响了整体的效率

线程池中线程复用原理

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

如何实现接口的幂等性

  • 唯一id. 每次操作,都根据操作和内容生成唯一的id, 在执行之前先判断id是否存在,如果不存在则执行后续操作,并且保存到数据库或者redis等。

  • 服务端提供发送token的接口,业务调用接口前先获取token,然后调用业务接口请求时,把token携带过去,务器判断token是否存在redis中,存在表示第一次请求,可以继续执行业务,执行业务完成后,最后需要把redis中的token删除
  • 建去重表。将业务中有唯一标识的字段保存到去重表,如果表中存在,则表示已经处理过了
  • 版本控制。增加版本号当版本号符合时,才能更新数据
  • 状态控制。例如订单有状态已支付未支付支付中支付失败, 当处于未支付的时候才允许修改为支付中等

Guess you like

Origin blog.csdn.net/jiayoubaobei2/article/details/121801480
Recommended