《Java架构师的第一性原理》24Java基础之并发第4篇常问面试题

JMM内存模型

睡眠与等待

interrupt/isInterrupted/interrupt区别

  • interrupt() 调用该方法的线程的状态为将被置为"中断"状态(set操作)
  • isinterrupted() 是作用于调用该方法的线程对象所对应的线程的中断信号是true还是false(get操作)。例如我们可以在A线程中去调用B线程对象的isInterrupted方法,查看的是A
  • interrupted()是静态方法:内部实现是调用的当前线程的isInterrupted(),并且会重置当前线程的中断状态(getandset)

sleep与wait区别

sleep属于线程类,wait属于object类;sleep不释放锁

终止线程方法

  • 使用退出标志,说线程正常退出;
  • 通过判断this.interrupted() throw new InterruptedException()来停止 使用String常量池作为锁对象会导致两个线程持有相同的锁,另一个线程不执行,改用其他如new Object()

ThreadLocal的原理和应用

原理:

线程中创建副本,访问自己内部的副本变量,内部实现是其内部类名叫ThreadLocalMap的成员变量threadLocals,key为本身,value为实际存值的变量副本

应用:

  • 用来解决数据库连接,存放connection对象,不同线程存放各自session;
  • 解决simpleDateFormat线程安全问题;
  • 会出现内存泄漏,显式remove..不要与线程池配合,因为worker往往是不会退出的;

threadLocal 内存泄漏问题

如果是强引用,设置tl=null,但是key的引用依然指向ThreadLocal对象,所以会有内存泄漏,而使用弱引用则不会;但是还是会有内存泄漏存在,ThreadLocal被回收,key的值变成null,导致整个value再也无法被访问到;解决办法:在使用结束时,调用ThreadLocal.remove来释放其value的引用;

如果我们要获取父线程的ThreadLocal值呢

ThreadLocal是不具备继承性的,所以是无法获取到的,但是我们可以用InteritableThreadLocal来实现这个功能。InteritableThreadLocal继承来ThreadLocal,重写了createdMap方法,已经对应的get和set方法,不是在利用了threadLocals,而是interitableThreadLocals变量。

这个变量会在线程初始化的时候(调用init方法),会判断父线程的interitableThreadLocals变量是否为空,如果不为空,则把放入子线程中,但是其实这玩意没啥鸟用,当父线程创建完子线程后,如果改变父线程内容是同步不到子线程的。。。同样,如果在子线程创建完后,再去赋值,也是没啥鸟用的

线程状态

背景

  先来探讨一个关于多线程的基础知识:java线程有多少种状态?根据JDK定义,答案是六种!为什么很多人给出的答案却是五种呢?这极有可能是将操作系统层面的线程状态和Java线程状态混为一谈了。因此,小编在翻阅JDK源码的基础上,介绍一下java线程的六种状态以及操作系统层面的五种状态,欢迎拍砖。

java线程状态

JDK中声明了六种Java线程状态,以枚举类的形式定义在Thread.State中,而且注释开篇撇清了和操作系统层面线程状态的关系——【这些状态是虚拟机状态,不反映任何操作系统的线程状态】,英文原文描述如下:

        /**
     * A thread can be in only one state at a given point in time.
     * These states are virtual machine states which do not reflect
     * any operating system thread states.
     *
     * @since   1.5
     * @see #getState
     */
    public class Thread implements Runnable {
    public enum State {
        NEW,
        RUNNABLE,
        BLOCKED,
        WAITING,
        TIMED_WAITING,
        TERMINATED;
    }
}

 简单来介绍一下这6种状态。

  1))NEW:新建状态,新创建一个线程对象时的初始状态,此时尚未调用 start() 方法。

  2)RUNNABLE:就绪状态,JAVA 线程把操作系统中的就绪和运行两种状态统一称为“就绪状态或者可运行状态”。英文相关描述如下:

  A thread in the runnable state is executing in the Java virtual machine but it may be waiting for other resources from the operating system such as processor.

  线程对象创建后,其它线程(比如main线程)调用了该对象的start()方法。该状态的线程位于可运行线程池中,等待被线程调度选中,获取cpu的使用权。

  3)BLOCKED:阻塞状态,表示线程进入等待状态,也就是线程因为某种原因放弃了 CPU 使用权,阻塞也分为几种情况 :

  • 等待阻塞:运行的线程执行了 Thread.sleep 、Object.wait()、 join() 等方法,JVM 会把当前线程设置为等待状态,当 sleep 结束、join 线程终止或者线程被唤醒后,该线程从等待状态进入到阻塞状态,重新抢占锁后进行线程恢复。

  • 同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被其它线程锁占用了,那么JVM会把当前的线程放入到锁池中。

  • 其他阻塞:发出了 I/O请求时,JVM 会把当前线程设置为阻塞状态,当 I/O处理完毕则线程恢复。

  4)WAITING:等待状态,没有超时时间,要被其它线程或者有其它的中断操作;即一个正在无限期等待另一个线程执行一个特别的动作的线程处于WAITING状态,英文原文如下:

A thread that is waiting indefinitely for another thread to perform a particular action is in this state.

一个线程进入 WAITING 状态是因为调用了方法Object.wait(), Thread.join()或者LockSupport.park()。然后会等其它线程执行一个特别的动作,比如:

  • 一个调用了Thread.join方法的线程会等待指定的线程结束。
  • 一个调用了某个对象的wait方法的线程会等待另一个线程调用此对象的notify() 或 notifyAll()。

  5)TIMED_WAITING:超时等待状态,超时以后自动返回;如下方法执行超时,就会进入超时等待状态:Thread.sleep(long)、Object.wait(long)、Thread.jjoin(long)、LockSupport.park(long)、LockSupport.parkNanos(long)、LockSupport.parkUntil(long)。

  6)TERMINATED:终止状态,表示当前线程执行完毕 。

  我们可以通过函数getState()来查看线程的当前状态:

 
    /**
     * Returns the state of this thread.
     * This method is designed for use in monitoring of the system state,
     * not for synchronization control.
     *
     * @return this thread's state.
     * @since 1.5
     */
    public State getState() {
        // get current thread state
        return jdk.internal.misc.VM.toThreadState(threadStatus);
    }

线程状态间的转换

借一个图来描述:

 

 

 关于具体的转换场景,图中描述的比较清楚,此处不再赘述。注意:

  1)sleep、join、yield时并不释放对象锁资源,而执行函数wait()时会释放锁,对象在被notify/notifyAll唤醒时,重新去抢夺获取对象锁资源。

  2)sleep可以在任何地方使用,而wait,notify,notifyAll只能在同步方法或者同步块中使用。

  3)调用obj.wait()会立即释放锁,以便其他线程可以执行notify(),但是notify()不会立刻立刻释放sycronized(obj)中的对象锁,必须要等notify()所在线程执行完同步方法或者同步块才会释放这把锁,然后供线程等待池的线程来抢夺对象锁。

  wait方法是Object的方法,线程释放锁,进入WAITING或TIMED_WAITING状态。等待时间到了或被notify/notifyAll唤醒后,回去竞争锁,如果获得锁,进入RUNNABLE,否则进入阻塞状态等待获取锁。

操作系统层面线程状态

  很多人会把操作系统层面的线程状态与java线程状态混淆,所以导致有的文章中把java线程状态写成是5种,在此我们说清楚一个问题,java线程状态是6个,操作系统层面的线程状态是5种,如下图所示:

 

 

下面分别介绍一下这5种状态:

  1)new :一个新的线程被创建,等待该线程被调用执行。

  2)ready :表示线程已经被创建,正在等待系统调度分配CPU使用权或者时间片已用完,此线程被强制暂停,等待下一个属于它的时间片到来。

  3)running :表示线程获得了CPU使用权,正在占用时间片。

  4)waiting :表示线程等待(或者说挂起),等待某一事件(如IO或另一个线程)执行完,让出CPU资源给其他线程使用。

  5)terminated :一个线程完成任务或者其它终止条件发生,该线程终止进入退出状态,退出状态释放该线程所分配的资源。

  需要注意的是,操作系统中的线程除去new 和terminated 状态,一个线程真实存在的状态是ready 、running和waiting 。

  Thread.State 中的RUNNABLE 状态涵盖了 操作系统层面的【可运行状态】、【运行状态】和【阻塞状态】(由于 BIO 导致的线程阻塞,在 Java 里无法区分,仍然认为是可运行,就好比我们在run()方法里调用IO方法,再者虽然有线程上下文切换但在JAVA层面还是运行的)。

线程池

介绍线程池的五种状态RUNNING、SHUTDOWN、STOP、TIDYING和TERMINATED,并简述五种状态之间的切换。

在线程池中,用了一个原子类来记录线程池的信息,用了int的高3位表示状态,后面的29位表示线程池中线程的个数。

在类ThreadPoolExecutor中定义了一个成员变量ctl,是个Integer的原子变量,用来记录线程池状态和线程池线程个数,另外定义了五个static final变量表示线程池的各个状态,部分JDK源码如下:

    private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
    private static final int COUNT_BITS = Integer.SIZE - 3;
    private static final int COUNT_MASK = (1 << COUNT_BITS) - 1;

    // runState is stored in the high-order bits
    private static final int RUNNING    = -1 << COUNT_BITS;
    private static final int SHUTDOWN   =  0 << COUNT_BITS;
    private static final int STOP       =  1 << COUNT_BITS;
    private static final int TIDYING    =  2 << COUNT_BITS;
    private static final int TERMINATED =  3 << COUNT_BITS;

线程池有5种状态:running,showdown,stop,Tidying,TERMINATED。

  • running:线程池处于运行状态,可以接受任务,执行任务,创建线程默认就是这个状态了

  • showdown:调用showdown()函数,不会接受新任务,但是会慢慢处理完堆积的任务。

  • stop:调用showdownnow()函数,不会接受新任务,不处理已有的任务,会中断现有的任务。

  • tidying:当线程池状态为showdown或者stop,任务数量为0,就会变为tidying。这个时候会调用钩子函数terminated()。

  • terminated:terminated()执行完成。

 

RUNNING 线程池的初始化状态是RUNNING,能够接收新任务,以及对已添加的任务进行处理。
  状态切换:线程池一旦被创建,就处于RUNNING状态,并且线程池中的任务数为0!

 

 

SHUTDOWN 线程池处在SHUTDOWN状态时,不接收新任务,但能处理已添加的任务。
  状态切换:调用线程池的shutdown()接口时,线程池由RUNNING -> SHUTDOWN。

 

 

STOP 线程池处在STOP状态时,不接收新任务,不处理已添加的任务,并且会中断正在处理的任务。
  状态切换: 调用线程池的shutdownNow()接口时,线程池由(RUNNING or SHUTDOWN ) -> STOP。

 

 

TIDYING 当所有的任务已终止,ctl记录的”任务数量”为0,线程池会变为TIDYING状态。
  当线程池变为TIDYING状态时,会执行钩子函数terminated()。terminated()在ThreadPoolExecutor类中是空的,若用户想在线程池变为TIDYING时,进行相应的处理;可以通过重载terminated()函数来实现。
  状态切换:当线程池在SHUTDOWN状态下,阻塞队列为空并且线程池中执行的任务也为空时,就会由 SHUTDOWN -> TIDYING。

 

 

TIDYING 当线程池在SHUTDOWN状态下,阻塞队列为空并且线程池中执行的任务也为空时,就会由 SHUTDOWN -> TIDYING。
  状态切换:线程池在STOP状态下,且执行的任务为空时,就会由STOP -> TIDYING。

 

 

TERMINATED 线程池彻底终止,就变成TERMINATED状态。
  状态切换:线程池处在TIDYING状态时,执行完terminated()之后,就会由 TIDYING -> TERMINATED。

Java中的线程池是如何实现的?

  • 线程中线程被抽象为静态内部类Worker,是基于AQS实现的存放在HashSet中;
  • 要被执行的线程存放在BlockingQueue中;
  • 基本思想就是从workQueue中取出要执行的任务,放在worker中处理;

如果线程池中的一个线程运行时出现了异常,会发生什么

如果提交任务的时候使用了submit,则返回的feature里会存有异常信息,但是如果数execute则会打印出异常栈。但是不会给其他线程造成影响。之后线程池会删除该线程,会新增加一个worker。

线程池原理

  • 提交一个任务,线程池里存活的核心线程数小于corePoolSize时,线程池会创建一个核心线程去处理提交的任务
  • 如果线程池核心线程数已满,即线程数已经等于corePoolSize,一个新提交的任务,会被放进任务队列workQueue排队等待执行。
  • 当线程池里面存活的线程数已经等于corePoolSize了,并且任务队列workQueue也满,判断线程数是否达到maximumPoolSize,即最大线程数是否已满,如果没到达,创建非核心线程执行提交的任务。
  • 如果当前的线程数达到了maximumPoolSize,还有新的任务过来的话,直接采用拒绝策略处理。

拒绝策略

  • AbortPolicy直接抛出异常阻止线程运行;
  • CallerRunsPolicy如果被丢弃的线程任务未关闭,则执行该线程;
  • DiscardOldestPolicy移除队列最早线程尝试提交当前任务
  • DiscardPolicy丢弃当前任务,不做处理

newFixedThreadPool (固定数目线程的线程池)

  • 阻塞队列为无界队列LinkedBlockingQueue
  • 适用于处理CPU密集型的任务,适用执行长期的任务

newCachedThreadPool(可缓存线程的线程池)

  • 阻塞队列是SynchronousQueue
  • 适用于并发执行大量短期的小任务

newSingleThreadExecutor(单线程的线程池)

  • 阻塞队列是LinkedBlockingQueue
  • 适用于串行执行任务的场景,一个任务一个任务地执行

newScheduledThreadPool(定时及周期执行的线程池)

  • 阻塞队列是DelayedWorkQueue
  • 周期性执行任务的场景,需要限制线程数量的场景

synchronized实现原理

contentionList(请求锁线程队列) entryList(有资格的候选者队列) waitSet(wait方法后阻塞队列) onDeck(竞争候选者) ower(竞争到锁线程) !ower(执行成功释放锁后状态);Synchronized 是非公平锁。

Synchronized 在线程进入 ContentionList 时,等待的线程会先尝试自旋获取锁,如果获取不到就进入 ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占 OnDeck 线程的锁资源。

底层是由一对monitorenter和monitorexit指令实现的(监视器锁)

每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程:

  • 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。
  • 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.
  • 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

ReentrantLock 是如何实现可重入性的 ?

内部自定义了同步器 Sync,加锁的时候通过CAS 算法 ,将线程对象放到一个双向链表 中,每次获取锁的时候 ,看下当前维 护的那个线程ID和当前请求的线程ID是否一样,一样就可重入了;

ReentrantLock如何避免死锁?

  • 响应中断lockInterruptibly()
  • 可轮询锁tryLock()
  • 定时锁tryLock(long time)

tryLock 和 lock 和 lockInterruptibly 的区别

(1):tryLock 能获得锁就返回 true,不能就立即返回 false,

(2):tryLock(long timeout,TimeUnit unit),可以增加时间限制,如果超过该时间段还没获得锁,返回 false

(3):lock 能获得锁就返回 true,不能的话一直等待获得锁

(4):lock 和 lockInterruptibly,如果两个线程分别执行这两个方法,但此时中断这两个线程, lock 不会抛出异常,而 lockInterruptibly 会抛出异常。

CountDownLatch和CyclicBarrier的区别是什么

CountDownLatch是等待其他线程执行到某一个点的时候,在继续执行逻辑(子线程不会被阻塞,会继续执行),只能被使用一次。最常见的就是join形式,主线程等待子线程执行完任务,在用主线程去获取结果的方式(当然不一定),内部是用计数器相减实现的(没错,又特么是AQS),AQS的state承担了计数器的作用,初始化的时候,使用CAS赋值,主线程调用await()则被加入共享线程等待队列里面,子线程调用countDown的时候,使用自旋的方式,减1,知道为0,就触发唤醒。

CyclicBarrier回环屏障,主要是等待一组线程到底同一个状态的时候,放闸。CyclicBarrier还可以传递一个Runnable对象,可以到放闸的时候,执行这个任务。CyclicBarrier是可循环的,当调用await的时候如果count变成0了则会重置状态,如何重置呢,CyclicBarrier新增了一个字段parties,用来保存初始值,当count变为0的时候,就重新赋值。还有一个不同点,CyclicBarrier不是基于AQS的,而是基于RentrantLock实现的。存放的等待队列是用了条件变量的方式。

CountDownLatch和CyclicBarrier区别

  • CountDownLatch用于主线程等待其他子线程任务都执行完毕后再执行,CyclicBarrier用于一组线程相互等待大家都达到某个状态后,再同时执行;
  • CountDownLatch是不可重用的,CyclicBarrier可重用

synchronized与ReentrantLock区别

  • 都是可重入锁;R是显示获取和释放锁,s是隐式;
  • R更灵活可以知道有没有成功获取锁,可以定义读写锁,是api级别,s是JVM级别;
  • R可以定义公平锁;Lock是接口,s是java中的关键字

什么是信号量Semaphore

信号量是一种固定资源的限制的一种并发工具包,基于AQS实现的,在构造的时候会设置一个值,代表着资源数量。信号量主要是应用于是用于多个共享资源的互斥使用,和用于并发线程数的控制(druid的数据库连接数,就是用这个实现的),信号量也分公平和非公平的情况,基本方式和reentrantLock差不多,在请求资源调用task时,会用自旋的方式减1,如果成功,则获取成功了,如果失败,导致资源数变为了0,就会加入队列里面去等待。调用release的时候会加一,补充资源,并唤醒等待队列。

Semaphore 应用

  • acquire() release() 可用于对象池,资源池的构建,比如静态全局对象池,数据库连接池;
  • 可创建计数为1的S,作为互斥锁(二元信号量)

可重入锁概念

(1):可重入锁是指同一个线程可以多次获取同一把锁,不会因为之前已经获取过还没释放而阻塞;

(2):reentrantLock和synchronized都是可重入锁

(3):可重入锁的一个优点是可一定程度避免死锁

ReentrantLock原理(CAS+AQS)

CAS+AQS队列来实现

(1):先通过CAS尝试获取锁, 如果此时已经有线程占据了锁,那就加入AQS队列并且被挂起;

(2):当锁被释放之后, 排在队首的线程会被唤醒CAS再次尝试获取锁,

(3):如果是非公平锁, 同时还有另一个线程进来尝试获取可能会让这个线程抢到锁;

(4):如果是公平锁, 会排到队尾,由队首的线程获取到锁。

AQS 原理

Node内部类构成的一个双向链表结构的同步队列,通过控制(volatile的int类型)state状态来判断锁的状态,对于非可重入锁状态不是0则去阻塞;

对于可重入锁如果是0则执行,非0则判断当前线程是否是获取到这个锁的线程,是的话把state状态+1,比如重入5次,那么state=5。而在释放锁的时候,同样需要释放5次直到state=0其他线程才有资格获得锁

AQS两种资源共享方式

  • Exclusive:独占,只有一个线程能执行,如ReentrantLock
  • Share:共享,多个线程可以同时执行,如Semaphore、CountDownLatch、ReadWriteLock,CyclicBarrier

CAS原理

内存值V,旧的预期值A,要修改的新值B,当A=V时,将内存值修改为B,否则什么都不做;

CAS的缺点:

(1):ABA问题;(2):如果CAS失败,自旋会给CPU带来压力;(3):只能保证对一个变量的原子性操作,i++这种是不能保证的

CAS在java中的应用:

(1):Atomic系列

公平锁与分公平锁

(1):公平锁指在分配锁前检查是否有线程在排队等待获取该锁,优先分配排队时间最长的线程,非公平直接尝试获取锁 (2):公平锁需多维护一个锁线程队列,效率低;默认非公平

独占锁与共享锁

(1):ReentrantLock为独占锁(悲观加锁策略) (2):ReentrantReadWriteLock中读锁为共享锁 (3):JDK1.8 邮戳锁(StampedLock), 不可重入锁 读的过程中也允许获取写锁后写入!这样一来,我们读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁, 乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行

4种锁状态

  • 无锁

  • 偏向锁 会偏向第一个访问锁的线程,当一个线程访问同步代码块获得锁时,会在对象头和栈帧记录里存储锁偏向的线程ID,当这个线程再次进入同步代码块时,就不需要CAS操作来加锁了,只要测试一下对象头里是否存储着指向当前线程的偏向锁 如果偏向锁未启动,new出的对象是普通对象(即无锁,有稍微竞争会成轻量级锁),如果启动,new出的对象是匿名偏向(偏向锁) 对象头主要包括两部分数据:Mark Word(标记字段, 存储对象自身的运行时数据)、class Pointer(类型指针, 是对象指向它的类元数据的指针)

  • 轻量级锁(自旋锁) (1):在把线程进行阻塞操作之前先让线程自旋等待一段时间,可能在等待期间其他线程已经 解锁,这时就无需再让线程执行阻塞操作,避免了用户态到内核态的切换。(自适应自旋时间为一个线程上下文切换的时间)

  • (2):在用自旋锁时有可能造成死锁,当递归调用时有可能造成死锁

  • (3):自旋锁底层是通过指向线程栈中Lock Record的指针来实现的

  • 重量级锁

轻量级锁与偏向锁的区别

(1):轻量级锁是通过CAS来避免进入开销较大的互斥操作

(2):偏向锁是在无竞争场景下完全消除同步,连CAS也不执行

自旋锁升级到重量级锁条件

(1):某线程自旋次数超过10次;

(2):等待的自旋线程超过了系统core数的一半;

读写锁了解嘛,知道读写锁的实现方式嘛

常用的读写锁ReentrantReanWritelock,这个其实和reentrantLock相似,也是基于AQS的,但是这个是基于共享资源的,不是互斥,关键在于state的处理,读写锁把高16为记为读状态,低16位记为写状态,就分开了,读读情况其实就是读锁重入,读写/写读/写写都是互斥的,只要判断低16位就好了。

zookeeper实现分布式锁

(1):利用节点名称唯一性来实现,加锁时所有客户端一起创建节点,只有一个创建成功者获得锁,解锁时删除节点。

(2):利用临时顺序节点实现,加锁时所有客户端都创建临时顺序节点,创建节点序列号最小的获得锁,否则监视比自己序列号次小的节点进行等待

(3):方案2比1好处是当zookeeper宕机后,临时顺序节点会自动删除释放锁,不会造成锁等待;

(4):方案1会产生惊群效应(当有很多进程在等待锁的时候,在释放锁的时候会有很多进程就过来争夺锁)。

(5):由于需要频繁创建和删除节点,性能上不如redis锁

volatile

volatile变量

(1):变量可见性

(2):防止指令重排序

(3):保障变量单次读,写操作的原子性,但不能保证i++这种操作的原子性,因为本质是读,写两次操作

volatile如何保证线程间可见和避免指令重排

volatile可见性是有指令原子性保证的,在jmm中定义了8类原子性指令,比如write,store,read,load。而volatile就要求write-store,load-read成为一个原子性操作,这样子可以确保在读取的时候都是从主内存读入,写入的时候会同步到主内存中(准确来说也是内存屏障),指令重排则是由内存屏障来保证的,由两个内存屏障:

  • 一个是编译器屏障:阻止编译器重排,保证编译程序时在优化屏障之前的指令不会在优化屏障之后执行。
  • 第二个是cpu屏障:sfence保证写入,lfence保证读取,lock类似于锁的方式。java多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个lock指令,就是增加一个完全的内存屏障指令。

 

posted @ 2023-12-21 13:57  沙漏哟  阅读(0)  评论(0编辑  收藏  举报