• 面试--并发多线程基础


    1、线程池的理解

    线程池就是要达到线程复用的目的,对线程的创建和消耗会涉及到cpu上下文切换、内存分配等,会带来性能的消耗;提高响应速度,任务到达时,任务可以不需要等到线程创建就能立即执行;提高线程的可管理性,统一的分配、调优和监控。

    public static final ThreadPoolExecutor THREAD_POOL_EXECUTOR = new ThreadPoolExecutor(
            APS * 2,                                   核心线程数
            APS * 4,                                   最大线程数
            KEEP_ALIVE_TIME,                           非核心线程空闲
            TimeUnit.SECONDS,                          单位
            new LinkedBlockingDeque<>(256),             阻塞队列
            new ThreadFactoryBuilder().build(),         线程工厂
            new ThreadPoolExecutor.CallerRunsPolicy()   阻塞策略-线程上限并且满队列,由主线程执行,线程一定会执行,但高并发下会影响效率
    );

    CPU密集型(计算密集型):系统的I/O读写效率高于CPU效率,大部分的情况CPU有许多运算需要处理,使用率很高。但I/O执行很快。

    I/O密集型:系统的CPU性能比磁盘读写效能要高很多,大多数情况是CPU在等I/O的读写操作,此时CPU的使用率并不高;

    计算公式分为:

    CPU密集型 : 线程数 = CPU核数+1 (现代CPU支持超线程)

    IO密集型: 线程数 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目

    2、线程回收

    线程池的队列满了的情况下,线程池会增加非核心线程。当任务处理完成后,非核心线程会被回收,一般不设置核心线程的回收,因为当没有任务时,是处于阻塞状态,不会占用cpu资源,当有任务时,减少创建线程带来的cpu上下文切换的损耗

    3、如何不进入队列,直接启用最大线程数

    SynchronousQueue队列

    4、实现多线程的方法
    • 继承Thread,重写run

    • 实现Runnable接口并重写run,如果已经存在继承关系的类实现线程,只能实现Runnable

    • 实现Callable重写call,通过FutureTask包装器来创建Thread线程,get方法获取带有返回值的线程

    5、Thread和Runnable区别

    Thread是一个类,类只能单一继承,Runnable是接口,接口支持多继承

    Runnable是线程的顶级接口,Thread也是实现了Runnable的接口,Runnable相当于一个任务,而Thread才是真正处理的线程,达到一个松耦合的设计目的。

    6、常用的线程池
    • newSingleThreadExecutor:创建一个单线程的线程池,保证任务的执行顺序按照提交顺序,适用于需要顺序执行的场景

    • newFixedThreadExecutor:创建固定大小的线程池,能控制最大并发数,防止过度竞争导致资源耗尽

    • newCachedThreadExecutor:创建缓存的线程池,当有新任务提交时,如果当前有空闲线程可用,就会被复用,否则会创建新的线程。当线程在60秒内未被使用时,将被终止并从缓存中移除。适合执行大量的短时间任务。如果线程池的当前规模超过了处理需求,则会进行回收

    • newScheduledThreadExecuto:支持定时以及周期性执行任务

    • WorkStealingPool:java8新加入的,基于 ForkJoinPool 实现的线程池,利用工作窃取算法实现任务的高效执行。适用于需要处理大量并行任务的场景,通常在异步编程中使用

    7、如何知道线程已经执行完

    在线程池内部,线程池调度线程来执行时是用run方法,ran方法结束并且等待run方法返回后,再去统计任务的完成数量。

    在线程池外部:

    • submit方法,线程执行完返回一个Future的返回值,get获取结果,没执行完会一直阻塞

    • countDownLatch计数器,为0时结束

    • isTermianted()方法,循环判断返回结果判断运行状态,Termianted时所有任务执行完

    8、线程调用两次start会出现什么

    IllegalThreadStateException 异常, 是 java.lang 包中的一个异常类,它表示线程处于不正确的状态下尝试执行某个操作

    9、如何停止一个正在运行的线程

    kill命令强制终止

    • 退出标志,run完成后线程终止

    • stop强行终止,是不安全的,可能线程任务还没有结束导致结果不正确的问题

    • 使用标志位: 这是一种常见的策略,通过设置一个布尔型的标志位来指示线程何时停止。运行的线程应该经常检查这个标志位

    • interrupt方法中断线程,配合interrupted方法,并不是强制中断,是告诉正在运行的线程可以停止了,是否要中断取决于正在运行的线程,能够保证线程运行结果的安全性。m1.interrupt(); // 阻塞时退出阻塞状态,阻塞方法(比如sleep(),wait(),join())会抛出 InterruptedException

    • 如果您的线程是由 ExecutorService 创建的,调用 future.cancel(true) 方法时,如果线程正在运行,它会被中断

    10、notify和notifyAll的区别
    • notify随机唤醒一个等待的线程进入锁等待池

    • notifyAll唤醒所有等待的线程进入等待池,竞争获取锁

    • 使用notifyAll可以确保所有等待的线程被唤醒,降低死锁或静态条件的风险,如果确定有一个线程需要被唤醒,可以使用notify提高效率

    11、sleep和wait区别
    • sleep:属于Thread类,程序暂停执行指定的时间,让出cpu,但不释放锁,当时间到自动回复运行状态

    • wait:属于Object类,线程会放弃对象锁,进入等待状态,使用notify后唤醒。

    • sleep就是让线程休眠一段时间,而wait是线程中通信让一个线程等待其他的线程的通知

    12、Thread类中的yield方法

    暂停当前正在执行的线程对象,让其他相同优先级的线程执行。是一个静态方法且只保证当前线程放弃cpu占用而不能保证其他线程一定占用cpu,执行yield的线程可能在进入到暂停状态后马上又执行。

    13、线程状态,Blocked和Waiiting的区别

    Blocked是阻塞状态,比如竞争同步锁时没有获取锁的执行会被阻塞等待,是被动的

    Waiiting是线程的等待,需要等待线程的特点操作才会被唤醒,可以使用wait()、join()方法,notify()唤醒,是主动的

    14、start和run的区别
    • start() 方法实际上是启动一个新线程并使新线程执行 run() 方法。新的线程会为其分配资源和调度下独立并行地执行其任务。

    • 直接调用 run() 方法不会启动新线程,而只会在当前线程中执行 run() 方法

    15、interrupted和isInterrupted方法区别
    • 所有者: interrupted()Thread 类的静态方法,它检查当前线程是否被中断。而 isInterrupted()Thread 类的实例方法,用于检查实例化的具体线程是否被中断。
    • 中断状态: interrupted() 方法调用后会对线程的中断状态进行复位(即将中断标志设置为 false),无论它之前是什么状态。而 isInterrupted() 方法则不会影响线程的中断状态。

    16、为什么wait、notify、notifyAll不在Thread类里
    • JAVA提供的锁是对象级的而不是线程级的,每个对象都有锁,通过线程获得。如果线程需要等待某些锁,调用对象中的wait方法,如果wait在Thread中,线程正在等待的是哪个锁就不确定了。

    • Java 的每一个对象都可以作为一个锁,线程通过 synchronized 关键字来获取对象的锁进行同步操作。wait(), notify(), 和 notifyAll() 方法就是用于在已经获取锁的线程中进行交互和通讯的。

    • wait、notify、notifyAll定义在Object中因为锁属于对象。

    17、为什么wait和notify要在同步块中调用
    • wait让线程进入到阻塞状态,notify唤醒线程,必然是成对出现的,实现多线程之间的通信

    • 由于这些方法是控制锁的行为,因此它们必须在 synchronized 代码块中使用。

    • 调用wait和notify之前,当前线程已经获得了该对象的锁,拥有对象锁通常需要在同步块内部执行后续的代码,这样避免竞态条件或者其他的并发问题,抛出IllegalMonitorStateException

    • wait通常是希望线程再某个特殊的状态被设置后再继续执行,调用notfy希望线程告诉其他等待的线程特殊的状态已经被设置,这个状态作为线程间通信的通道

    18、解决死锁

    多个线程执行过程中争夺同一个共享资源造成的相互等待的过程

    死锁的四个条件:

    互斥条件:共享资源只能被一个线程占用

    请求和保持条件:线程1获取到共享资源X,等待共享Y的时候,不释放X

    不可抢占条件:其他线程不能强行占用线程1的资源

    循环等待条件:线程1等待线程2占用的,线程2等待线程1占用的

    破坏其中一个即可,互斥是不能破坏的。

    19、线程安全

    多线程访问通同一代码,不会产生不确定的结果。具体表现三个方面

    • 原子性:一段程序只能由一个线程完整的执行完成,不能存在多个线程干扰。CPU的上下文切换导致原子性的核心,使用synchronized解决

    • 可见性:多线程下,读和写发生在不同的线程,可能存在一个线程对共享变量的更改对其他线程不是实时可见的

    • 有序性:指令重排序,可能导致可见性的问题。使用volatile解决

    20、有界阻塞队列和无界阻塞队列
    • 阻塞队列:如果队列为空,消费者阻塞,唤醒生产者。如果队列满,生产者阻塞,唤醒消费者

    • 有界阻塞队列能够容纳的元素个数,通常是有界的。ArrayBlockingQueue基于数组结构的阻塞队列,数组的有长度限制,为了达到循环生生产和消费目的,用到了循环数组。

    • 无界队列是没有固定大小的队列,不是没有限制而是存储量很大,比如LinkedBlockingQueue,如果并发大情况下,可以无限制添加,导致内存溢出

    21、ArrayBlockingQueue的理解
    • 阻塞队列是在队列的基础上增加了两个附加条件,队列为空的时候,获取元素的线程会等待队列变为空,队列满时存储元素的线程会等待队列可用。就比如生产者消费者模型。ArrayBlockingQueue是基于数组的阻塞队列,队列元素是存储在一个数组结构里,并且由于数组有长度限制,用到了循环数组,达到循环生产和循环消费的目的。
    • 内部就是两个指针都是从队首向队尾移动,保证队列的先进先出原则,利用指针和数组,行成环状。
    • 底层使用的是ReentrantLock保证互斥性,存取都是同一把锁,操作的是同一个数组对象,存取互相排斥

    22、阻塞队列被异步消费是怎么保持顺序的

    阻塞队列是先进先出的,在阻塞队列中,使用了condition条件维护两个等待队列,一个是队列为空的时候存储被阻塞的消费者,一个是队列满了的时候存储被阻塞的生产者。

    对于消费过程有两种情况:第一种是,阻塞队列里已经包含很多任务,启动多个消费者去消费时候,每个消费者线程去阻塞队列获取任务的时候,要先获取排他锁。第二种是,多个消费者因为阻塞队列没有任务而阻塞,线程是按照先进先出的顺序存储到condition等待队列中,然后严格按照先进先出顺序唤醒。

    23、保证多线程的顺序执行

    使用join()方法

    24、submit和execute区别
    • submit方法是 ExecutorService 接口中定义的方法,它用于提交一个实现了 CallableRunnable 接口的任务,并返回一个 Future 对象,用于跟踪任务的执行状态。

    • Callable是一个可以返回结果的任务,它的 call 方法可以返回一个结果,可以通过 Future 对象获取。

    • Runnable是一个无返回结果的任务,submit 返回的 Future 对象将总是包含一个 null 结果。

    • execute方法是 Executor 接口中定义的方法,它用于执行一个 Runnable 任务,但它不返回任何结果,也无法获取任务的执行状态

    25、ThreadLocal理解

    线程隔离机制,多线程下对于共享变量访问的安全性。

    一般多线程对共享变量的处理,是对共享变量加锁,保证只有一个线程对共享变量进行操作。

    加锁会带来性能响,ThreadLocal就是空间换时间,每个线程里都有一个容器存储共享变量的副本,然后每个线程只对自己的变量副本进行操作。

    26、ThreadLocal会存在内存泄漏吗

    每个线程都有一个成员变量ThreadLocalMap,是在这个map中保存了一份数据副本,key是引用(弱引用),value是副本。

    弱引用只要JVM发现就会回收,一旦回收,key就为null,导致这个内存永远无法被访问,造成内存泄漏。

    优化:

    ThreadLocal为了避免造成内存泄漏,会找到并清理Entry里面的key为null的数据。

    调用 set()方法时,ThreadLocal 会进行采样清理、全量清理,扩容时还会继续检查。

    调用 get()方法时,如果没有直接命中或者向后环形查找时也会进行清理。

    调用 remove()时,除了清理当前 Entry,还会向后继续清理。

    避免:

    每次使用ThreadLocal后,主动调用remove移除

    将 ThreadLocal 变量尽可能地定义成 static final,避免频繁创建 ThreadLocal 实例。

    27、volatile是什么?
    • 保证不同线程对变量进行操作时的可见性,一个线程修改某个变量的值,对其他来说都是立即可见的,会强制将修改的值写入主存

    • 禁止进行指令重排序,可以保证部分有序性

    • 造成重排序问题:

    指令的编写顺序和执行顺序不一致

    如果加入了volatile,就不会触发编译器优化,同时在jvm里插入内存屏障指令避免重排序

    • 造成可见性的问题:

    cpu高速缓存,使用了三级缓存解决cpu运算效率和IO效率问题,在使用volatile修饰共享变量,jvm会自动增加一个lock指令,添加总线锁或者缓存锁

    总线锁:锁定cpu前端总线,同一时刻只能有一个线程去和内存通信

    缓存锁:对总线锁的优化,总线锁导致cpu使用效率下降,缓存锁只对cpu三级缓存中的目标数据加锁

    28、volatile和synchronized区别
    • valatile本质告诉jvm当前变量的值是不确定的,需要从主存中读取

    • synchronized是锁定当前变量,只有当前线程可以访问,其他线程阻塞

    • volatile仅能使用在变量,实现变量的修改可见性

    • synchronized可以使用在变量、方法、类,保证变量的修改可见性和原子性

    • volatile不会造成线程的阻塞

    • synchronized可能会阻塞

    29、lock和synchronized的区别
    • synchronized是关键字,可以通过两种方式控制锁的粒度,一是修饰在方法上,二是修饰在代码块上,如果锁对象是静态对象或者类对象,这个锁对象就是全局锁,如果锁对象是普通实例对象,锁范围取决于这个实例的生命周期。
    • lock是接口,提供lock和unlock方法,在这两个方法之间的代码保证线程安全性,提供了tryLock方法通过放回true/false告诉当前线程是否有其他线程正在使用锁。
    • lock比synchronized的灵活性更高。
    • lock提供了公平锁和非公平锁,公平锁是指竞争锁资源时,如果已经有其他线程症状排队等待锁释放,那么当前竞争锁资源的线程无法插队,非公平锁就是不管是否有线程等待锁,都会尝试去竞争锁,synchronized就是一种非公平锁。

    30、可重入锁
    • 可重入锁(Reentrant Lock)是一种能够被单个线程多次获取的锁,也就是说,如果当前线程已经持有了这个锁,再次请求持有时,请求就会成功。
    • 每一个锁关联一个线程持有者和计数器,当计数器为0代表没有线程占用,那么线程都可以去获取锁。当某一个线程请求成功,JVM记下锁的持有线程,其他线程再去获取锁时必须等待,而持有锁的线程再次请求这个锁,就可以再次拿到锁,计数器同步增加,线程退出同步代码块时递减。
    • 关于为何需要可重入锁,这与我们在程序设计中经常使用的函数或方法的递归调用有关。一个很实际的例子是,如果一个线程已经在一个synchronized方法内部,那么它能再次调用这个对象的其他synchronized方法或者同个synchronized方法(自己调用自己,即递归)。
    • 如果没有可重入锁的设计,那时线程再次获取锁时,因为这把锁已被自己持有,就会造成该线程死锁。而可重入锁的设计则保证了这个线程可以再次获取到那把已经被自己持有的锁,进而避免了死锁情况的发生。

    31、对ReentrantLock理解

    ReentrantLock是一种可重入排他锁

    提供了阻塞竞争锁和非阻塞竞争锁,分别是lock和trylock

    • 锁的竞争,通过互斥变量,使用CAS机制实现

    • 没有竞争到锁的线程,使用了AbstractQueueSynchronizer队列同步器存储,底层是先进先出的双向链表,锁释放之后,会从AQS队列中头部唤醒下一个等待锁的线程

    • 设置公平和非公平,主要是体现在竞争锁的时候,是否需要判断AQS队列存在等待中的线程,公平锁就是竞争资源的时候判断AQS同步队列中是否有等待的线程,如果有就加入到队列的尾部,非公平锁就是不管有没有等待,都会尝试抢占锁,抢不到再加入到锁队列。

    • 可重入的概念,在AQS有一个成员变量来保存当前获得锁的线程,当同一个线程下次再来竞争锁的时候,就不会走锁竞争的逻辑,直接增加重入次数

    32、ReentrantLock如何实现公平和非公平的的

    公平:竞争锁资源的线程,按照顺序分配锁

    非公平:竞争锁资源的线程,允许插队来抢占资源锁

    内部使用AQS实现锁资源的竞争,没有竞争到锁的线程,加入到AQS同步队列,这个队列是一个先进先出的双向链表实现的,公平锁就是竞争资源的时候判断AQS同步队列中是否有等待的线程,如果有就加入到队列的尾部,非公平锁就是不管有没有等待,都会尝试抢占锁,抢不到再加入到锁队列。

    ReentrantLock和synchronized默认都是非公平锁,主要是考虑到性能方面的原因,因为一个竞争锁的线程如果按照公平的策略去阻塞等待,同时AQS再把等待队列里面的线程唤醒,涉及到内核态的切换,对性能应较大,非公平锁提升了锁竞争的性能。

    33、synchronized和ReentrantLock区别
    • 都是阻塞式的同步,进行线程阻塞和唤醒的代价是较高的

    • synchronized是java关键字,原生语法层面的互斥,需要jvm实现,进行编译在同步块前后monitorenter和monitorexit两个字节码指令,执行指令时,获取锁+1,释放锁-1,为0时释放。修饰方法时是ACC_SYNCHRONIZED标识是否是一个同步方法。

    • ReentrantLock是java.util.concurrent下的互斥锁,需要lock()和unlock()以及try/finally完成。长期持有锁不释放时,等待线程可以选择放弃等待。可以设置公平锁。

    34、对synchronized的了解

    jdk1.6之前是重量级锁,解决多个线程间访问资源的同步性,保证被修饰的方法或代码块在任意时刻只有一个线程执行。

    原生语法层面的互斥,需要jvm实现,而操作系统实现线程切换需要在用户态和内核态直接的转化,导致效率低,JDK1\6以后,引入了偏向锁和轻量级锁的机制。

    35、偏向锁和轻量级锁
    • 偏向锁:把当前锁偏向于某个线程,通过CAS修改偏向锁标记

    • 轻量级锁(自旋锁):多次自旋重试竞争锁

    • synchronized尝试使用偏向锁去竞争锁,如果能竞争到就是成功直接返回,否则当前锁已经偏向了其他线程。需要将锁升级到轻量级锁,竞争锁的线程根据自适应自旋次数去尝试抢占资源,如果还没有竞争到锁,升级到重量级锁,没有竞争到就会被阻塞,需要等待获得锁的线程来触发唤醒。

    • 总的来说,synchronized的升级思想就是,

    36、对synchronized的使用
    • 修饰实例方法:对当前对象实例加锁,进入代码前要获取锁

    • 修饰静态方法:当前类加锁,作用于类的所有对象实例。当一个类的静态方法使用 synchronized 关键字修饰时,这个方法会获取该类的类级别锁(也就是类对象的锁),因此会影响所有使用该类的实例。同时,如果一个线程在调用一个实例对象的非静态同步方法时,另一个线程需要调用该实例对象所属类的静态同步方法,两者之间不会产生互斥,因为它们分别使用的是实例对象锁和类级别锁。

    • 修饰代码块:指定加锁对象,对给定对象加锁

    • 在Java中,字符串常量池是一种特殊的存储机制,它会缓存字符串字面量,以便在后续使用相同字符串字面量时能够重用同一个对象。因此,如果使用一个字符串常量作为锁对象,可能会导致多个线程使用相同的锁对象,从而破坏了锁的独立性。

    37、synchronizedMap和concurrentHashMap的区别

    synchronizedMap在调用map所有方法时,对整个map进行同步,而concurrentHashMap更加细致,对map所有桶加锁,只要有一个线程访问map,其他线程都无法进入map,而如果一个线程再访问concurrentHashMap某个桶时,其他线程仍然可以对map操作。

    38、对concurrentHashMap的理解
    • 在JDK1.8中,由数组、单向链表、红黑树组成。默认长度16的数组,核心是哈希表。当数组长度大于64,链表长度大于8,单链表转为红黑树。

    • JDK1.8中是对指定的节点加锁,保证数据更新的安全性。1.7中锁定的是segment,锁的范围更大,性能更低

    • 数组长度不够时,进行扩容引入了多线程并发扩容机制,多个线程对原始数组进行分片,每个线程负责 一个分片的数据迁移,提升了扩容过程中数据迁移的效率

    • concurrentHashMap中 有一个size方法获取总的元素个数,而多线程并发中,在保证原子性的前提下来实现元素个数的累加,性能低。优化在:线程竞争不激烈时采用CAS实现元素个数的原子递增。如果激烈,使用一个数组维护元素个数,如果要增加总的元素个数,直接从数组中随机选择一个通过CAS实现原子递增。

    • 它是通过 CAS 或者 synchronized 来保证线程安全的,并且缩小了锁的粒度,查询性能也更高。

    39、concurrentHashMap的size和put方法

    size() 方法被用来获取映射中键值对的数量。这是一个估计的数字,因为并发更新可能会导致计数器的变化。

    put() 操作是线程安全的,可以在并发环境下安全使用,因为底层会使用一个称为段锁(Segment Lock)的锁机制在并发环境下提供高效的数据更新读写操作

    • 为什么不直接加锁呢?

    concurrentHashMap中对于size数量一致性的需求不大,更多的是保证数据存储的安全性,多线程下获取到size后可能其他线程又进行更改,也不是最新的值。

    • 如果要获取一个精确的怎么办呢?

    如果需要一个线程安全的,且可以返回精确size的容器,Java提供了类似 java.util.concurrent.CopyOnWriteArrayList 的并发容器,这些容器在添加和删除元素时,才会使用锁,但是在获取size()时不会使用。因为容器实际上维护了一个内部数组的拷贝,在进行修改操作时,它们将当前的数组进行拷贝,然后在拷贝上进行修改,最后再将拷贝设置为当前的数组。这使得在获取size()时,无需进行锁定,而可以直接返回当前数组的长度。但这类容器的缺点是修改操作需要额外的内存和CPU时间。

    • 为什么key不允许是null?

    底层put源码中,为空会抛出空指针异常,是避免在多线程下出现歧义,如果k或v为null,通过get(k)获取v的时候,如果返回的结果null,不能确定是k本身不存在还是v就是null

    40、ConcurrentHashMap 如何实现安全的

    在 JDK 1.7 中使用的数组+链表的结构,其中数组分为两类,大树组 Segment 和 小数组 HashEntry,而加锁是通过给 Segment 添加ReentrantLock 重入锁来保证线程安全的。

    在 JDK1.8 中使用的是数组+链表+红黑树的方式实现,它是通过 CAS 或者 synchronized 来保证线程安全的,并且缩小了锁的粒度,查询性能也更高。

    添加元素时首先会判断容器是否为空,如果为空则使用 volatile 加 CAS 来初始化,如果容器不为空,则根据存储的元素计算该位置是否为空。如果根据存储的元素计算结果为空则利用 CAS 设置该节点;如果根据存储的元素计算为空不为空,则使用 synchronized ,然后,遍历桶中的数据,并替换或新增节点到桶中,最后再判断是否需要转为红黑树。

    41、守护线程

    守护线程就是一种后台服务线程,是为用户线程服务的,当一个程序中的所有用户线程都执行完成之后程序就会结束运行,程序结束运行时不会管守护线程是否正在运行。

    JVM垃圾回收机制就是典型的守护线程,处理用户线程产生的内存垃圾,用户线程结束,守护线程也就没有意义了。

    守护线程不能用在线程池或者一些IO任务的场景里,因为一旦JVM退出后,守护线程也会退出,可能导致任务没有执行完成或者资源没有正确释放的问题。

    42、CompletableFuture理解(如何让一个线程等待另一个线程执行完成后触发)

    如果是括号的问题,其实还有join()方法,使用CountDownLatch。

    主要用于当一个任务完成后触发一个后续的任务。

    • thenCombine:两个任务组合一起,并发执行,当两个任务都执行结束后触发时间回调

    • thenCompose:两个任务组合一起,串行执行,第一个任务执行完自动触发第二份

    • thenAccept:第一个任务执行后触发第二个任务,并且第一个任务的执行结果作为第二个任务的参数看,不返回结果

    • thenApply:和accept一样,有返回值

    • thenRun:第一个任务执行后执行一个实现了Runnable的任务

    43、CAS

    compare and swap,多线程下对于共享变量的修改的原子性

    只有当预期值和内存位置的值相同时,才将新值写入内存。

    1. 修改前记录数据的内存地址V;

    2. 读取数据的当前的值,记录为A;

    3. 修改数据的值变为B;

    4. 查看地址V下的值是否仍然为A,若为A,则用B替换它;若地址V下的值不为A,表示在自己修改的过程中,其他的线程对数据进行了修改,则不更新变量的值

    造成ABA的问题:

    变量i=100,线程A 100->80,线程B 80->100,线程C 100-80,执行顺序ACB,其实数据已经被更改了,但是A没有发现,解决方法加版本号。

    CAS是一个本地方法,就是先从内存读取值再去比较,最后修改,都会存在原子性的问题,那么在多核cpu下,增加一个lock指令对缓存或者总线加锁,保证原子性。

    44、AQS

    AQS是JUC包下Lock锁的底层实现,也就是多线程同步器,主要两个部分组成

    一是volatail修饰的 state变量,作为一个竞态条件

    二是双向链表维护的先进先出的等待队列

    工作原理是,多个线程通过对state共享变量修改实现竞态条件,失败的线程加入等待队列并阻塞,抢占到竞态资源的线程释放后,按照先进先出实现唤醒

    有两种资源共享方式:

    一是独占资源,一个时刻只有一个线程获取竞态资源,比如ReentrantLock实现排他锁

    二是共享资源,一个时刻多个线程可以获得竞态资源,比如CountDownLatch唤醒多个线程

    45、AQS为什么使用双向链表

    双向链表的特点是,一个指针指向前置节点,一个指针指向后继节点,在插入和删除的时候更高效。

    那么AQS在使用双向链表的时候,

    一是,没有竞争的锁的线程在阻塞之前,要判断前置节点的状态,避免存在异常线程导致无法唤醒后续线程,如果没有双向链表的前置节点,就需要从head遍历,性能低

    二是,lock接口中有一个方法,处于阻塞的线程允许被中断,也就是没有竞争到锁的线程加入到队列等待后,允许外部线程通过interrupt方法唤醒并中断,中断的线程状态变成cancelled,是不需要竞争锁的,但是仍在队列中,后续竞争锁时,这个节点需要移除,否则导出其他线程无法被正常唤醒

    三是,避免线程阻塞和唤醒的开销,刚加入到链表的线程,会通过自旋的方式尝试竞争锁,但是按照公平锁的设计,只有头结点的下一个节点才去竞争锁,否则会出现大量的线程在阻塞之前尝试竞争锁带来大的性能开销,所以在加入到链表尝试竞争锁之前,判断前置节点是不是头结点,如果不是就没必要触发竞争。

    46、伪共享

    cpu里设计三级缓存,去平衡cpu和内存直接的速度差异,一次性读取64个字节作为一个缓存行,缓存到cpu高速缓存里。java中一个long是8个字节,一个缓存行可以存储8个long的变量,也就是如果一个存储器的位置被引用,附加的位置也会被引用,有效的减少和内存的交互次数,避免了cpu的io等待,提升cpu的利用率。

    如果多个线程修改同一个缓存行里的多个独立变量,基于缓存一致性协议,影响了彼此的性能,比如线程A更新变量A,线程B更新变量B,AB都在一个缓存行,每个线程都要去竞争缓存行的所有权,基于缓存一致性,一旦运行在某个cpu上的线程获得了所有权并执行修改,导致其他cpu的缓存行失效,就是伪共享。

    解决:java8里,提供了@Contented,通过缓存行填充来解决伪共享的问题,被注解的数据会被加载到独立的缓存行上。

    47、对 Happens-Before 的理解

    在多线程环境下,因为指令重排序的存在会导致数据的可见性问题,也就是 A 线程修改某个共享变量对 B 线程不可见。

    Happens-Before 只要不对结果产生影响,仍然允许指令的重排序。

    程序顺序规则:在一个线程中,按照程序代码的顺序,前面的操作 Happens-Before 于后续的操作。

    监视器锁规则:对一个锁的解锁 Happens-Before 于随后对这个锁的加锁。

    volatile 变量规则:对一个 volatile 变量的写操作 Happens-Before 于随后对这个变量的读操作。

    传递性:如果 A Happens-Before B,且 B Happens-Before C,则可以得出 A Happens-Before C。

    线程 start 规则:线程的启动(start)操作 Happens-Before 于该线程的任何操作。

    线程 join 规则:在一个线程中,对其他线程的 join 操作 Happens-Before 于被 join 线程的任何操作。

    线程中断规则:对线程的 interrupt() 方法的调用 Happens-Before 于被中断线程的代码检测到中断事件的发生。

    对象终结规则:对象的构造函数执行 Happens-Before 于 finalize() 方法的开始执行。

    48、SimpleDateFormat是线程安全的吗

    不是,内部有一个Calendar对象引用,存储相关的日期信息。如果作为共享资源使用,共享Calendar引用导致脏读。

    定义成局部变量,每个线程调用的时候创建一个新的实例

    加同步锁,只允许一个线程使用

    1.8加入了安全的API,java.time,比如LocalDateTimer、SimpleDateFormatte

  • 相关阅读:
    linux开机自动启动java的jar包项目及开机自动启动Nacos的配置
    3.DIY可视化-拖拽设计1天搞定主流小程序-前后分离框架运行
    Multitouch 1.27.28 免激活版 mac电脑多点触控手势增强工具
    CDH大数据平台 28Cloudera Manager Console之superset相关包安装(markdown新版二)
    【自定义列表头】vue el-table表格自定义列显示隐藏,多级表头自定义列显示隐藏,自由搭配版本和固定列版本【注释详细】
    Spring核心注解详解(一)
    在C#中使用RabbitMQ做个简单的发送邮件小项目
    前端常用组件大全
    Nvm,Nrm使用教程
    Day692.Tomcat如何隔离Web应用 -深入拆解 Tomcat & Jetty
  • 原文地址:https://blog.csdn.net/m0_63297646/article/details/134187375