多线程常见面试题一

 参考:

1、Java线程安全的类 

1、String 因其被final 修饰,所以具有不可变性,天生就线程安全
2、Vector StackHashTableStringBuffer
通过synchronized 关键字给方法加上内置锁来实现线程安全
3、原子类Atomicxxx—包装类的线程安全类
AtomicLongAtomicInteger等等 
Atomicxxx 是通过Unsafe 类的native方法实现线程安全的
通过{unsafeCAS操作Volitle修饰属性,保证可见}
4、BlockingQueue 和BlockingDeque
BlockingDeque接口继承了BlockingQueue接口,
BlockingQueue 接口的实现类有ArrayBlockingQueue ,LinkedBlockingQueue ,PriorityBlockingQueue 而BlockingDeque接口的实现类有LinkedBlockingDeque
BlockingQueue和BlockingDeque 都是通过使用定义为final的ReentrantLock作为类属性显式加锁实现同步的
5、CopyOnWriteArrayList和 CopyOnWriteArraySet (读写分离)
CopyOnWriteArraySet的内部实现是在其类内部声明一个final的CopyOnWriteArrayList属性,并在调用其构造函数时实例化该CopyOnWriteArrayList,CopyOnWriteArrayList采用的是显式地加上ReentrantLock实现同步,而CopyOnWriteArrayList容器的线程安全性在于在每次修改时都会创建并重新发布一个新的容器副本,从而实现可变性。
6、Concurrentxxx
最常用的就是ConcurrentHashMap,当然还有ConcurrentSkipListSet和ConcurrentSkipListMap等等。
关于ConcurrentHashMap的详细介绍请查看《Java集合》中CurrentHashMap相关部分的内容
7、ThreadPoolExecutor
ThreadPoolExecutor也是使用了ReentrantLock显式加锁同步
8、Collections中的synchronizedCollection(Collection c)方法可将一个集合变为线程安全,其内部通过synchronized关键字加锁同步, 
可以使用 Collections.synchronizedList(); 得到一个线程安全的 ArrayList。

2、volatile关键字作用

volatile 关键字的作用

常用于保持变量对所有线程的可见性(随时见到的都是最新值)和防止指令重排序。
可见性是指:一个线程对变量的修改对其他线程是可见的。volatile是通过定义特殊规则来实现可见性的:
  • read、load、use动作必须连续出现。
  • assign、store、write动作必须连续出现。

所以,使用volatile变量能够保证:

  • 每次读取前必须先从主内存刷新最新的值。
  • 每次写入后必须立即同步回主内存当中。

volatile 是通过 内存屏障来实现指令重排序的。

偏序关系Happens-Before内存模型中,指令重排技术大大提高了程序执行效率。但同时也带来了一些问题。
比如一个比较经典的问题就是基于 DCL 双锁检查的单例设计模式,如果没有把成员 instance 声明为 valotile, 那么在创建对象的时候将会对 创建对象操作这个底层实现进行排序优化,创建对象的抽象过程我们认为应该是先分配内存,然后初始化对象,最后返回对象的引用,但是实际上 cpu 会将这个过程进行 重排序,实际的创建过程是 先分配内存,然后返回对象引用,最后初始化对象。所以这个重排序就导致了,如果一个线程刚好处于创建单例对象的第二步和第三步之间,如果另一个线程调用getInstance方法,
由于instance已经指向了一块内存空间,从而if条件判为false,方法返回instance引用,用户得到了没有完成初始化的“半个”单例。所以如果要实现安全的 单例,就可以使用 对instance 成员生声明为 volatile 来实现。
JMM内存屏障插入策略
  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的前面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障

注意:

volatile 是没有原子性的。

但对volatile的使用过程中很容易出现的一个问题是:

错把volatile变量当做原子变量。

出现这种误解的原因,主要是volatile关键字使变量的读、写具有了“原子性”。然而这种原子性仅限于变量(包括引用)的读和写,无法涵盖变量上的任何操作,即:

  • 基本类型的自增(如count++)等操作不是原子的。
  • 对象的任何非原子成员调用(包括成员变量成员方法)不是原子的。

如果希望上述操作也具有原子性,那么只能采取锁、原子类更多的措施。

参考:

3、有哪些锁?可重入不可重入?自旋锁互斥锁可重入?

3.1、独享锁/共享锁

独享锁是指该锁一次只能被一个线程所持有。 (ReentrantLock、 Synchronized)

共享锁是指该锁可被多个线程所持有。 (ReadWriteLock 读锁是共享锁,写锁是独享锁。 )

3.2、 公平锁/非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁。

非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能会造成饥饿现象。

Synchronized 非公平锁。ReentrantLock 默认是非公平锁,不过可以通过构造函数传入 true 这个 boolean 值来指定该锁是公平锁,。非公平锁的优点在于吞吐量比公平锁大。

3.3、可重入锁

可重入锁又名递归锁,是指同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。

ReentrantLock和Synchronized都是可重入锁。可重入锁的一个好处是可一定程度避免死锁,比如 A B 方法都锁定的是同一个对象,然后A 方法中调用了 B 方法,如果外层方法获取锁之后内层方法还需要获取锁,那么这个线程就会等待持有锁的线程释放锁,但是持有锁的线程是它本身,所以它在等待自己释放一个自己持有的锁,就陷入了死锁。

需要注意的是,可重入锁加锁和解锁的次数要相等。不过一般加锁和解锁都是成对出现的,所以这个一般不会出现问题。

3.4、乐观锁/悲观锁

乐观锁/悲观锁不是指具体类型的锁,而是看待并发的角度。
悲观锁认为存在很多并发更新操作,采取加锁操作,如果不加锁一定会有问题

乐观锁认为不存在很多的并发更新操作,不需要加锁。数据库中乐观锁的实现一般采用版本号,Java中可使用CAS实现乐观锁。

3.5、分段锁

分段锁是一种锁的设计,并不是一种具体的锁。对于 JDK 1.7 以 1.7 以前的 ConcuttentHashMap 就是通过分段锁实现高效的并发操作。

3.6、自旋锁和阻塞锁

自旋锁是指尝试获取锁的线程不会阻塞,而是采用一段空循环的方式等待持有锁的线程释放锁,然后获取锁。好处是减少上下文切换,缺点是一直占用CPU资源。

阻塞锁就是当获取不到的时候就进入阻塞状态,等待操作系统唤醒,需要上下文切换,开销大。

3.7、 偏向锁/轻量级锁/重量级锁

这是jdk1.6中对Synchronized锁做的优化

从jdk1.6开始为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。锁共有四种状态,级别从低到高分别是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。随着竞争情况锁状态逐渐升级、锁可以升级但不能降级。

1.偏向锁是为了避免某个线程反复获得/释放同一把锁时的性能消耗。设计者发现这样一个现象,即使是在并发环境中,某个对象的锁也往往是被同一个对象多次持有,为了降低这个线程反复获取和释放同一把锁的开销,约定如果仍然是同个线程去获得这个锁,如果当前的锁是偏向锁,那么当前线程会直接进入同步块,不需要再次获得锁。
2.而轻量级锁和自旋锁都是为了避免直接调用操作系统层面的互斥操作,因为挂起线程是一个很耗资源的操作。
为了尽量避免使用重量级锁(操作系统层面的互斥),首先会尝试轻量级锁,轻量级锁会尝试使用CAS操作来获得锁,如果轻量级锁获得失败,说明存在竞争。但是也许很快就能获得锁,就会尝试自旋锁,将线程做几个空循环,每次循环结束时尝试获得锁。如果自旋锁也失败,那么只能升级成重量级锁。
3.可见偏向锁,轻量级锁,自旋锁都是乐观锁。

4、针对 Synchronized 关键字的锁优化进制

锁的优化机制主要有 :锁粗化,锁消除,JDK 1.6 还引入了 偏向锁,自旋锁,还有轻量级锁。

4.1 锁粗化:

如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展到整个操作序列的外部,这样就只需要加锁一次就够了

4.2 锁消除:

如果你定义的类的方法上有同步锁,但在运行时,却只有一个线程在访问,此时逃逸分析后的机器码,会去掉同步锁运行。

4.3 自旋锁:

自旋锁是指尝试获取锁的线程不会阻塞,而是采用循环的方式尝试获取锁。好处是减少上下文切换,缺点是一直占用CPU资源。

4.4 偏向锁 / 轻量级锁

1.偏向锁是为了避免某个线程反复获得/释放同一把锁时的性能消耗,如果仍然是同个线程去获得这个锁,尝试偏向锁时会直接进入同步块,不需要再次获得锁。
2.而轻量级锁和自旋锁都是为了避免直接调用操作系统层面的互斥操作,因为挂起线程是一个很耗资源的操作。
为了尽量避免使用重量级锁(操作系统层面的互斥),首先会尝试轻量级锁,轻量级锁会尝试使用CAS操作来获得锁,如果轻量级锁获得失败,说明存在竞争。但是也许很快就能获得锁,就会尝试自旋锁,将线程做几个空循环,每次循环时都不断尝试获得锁。如果自旋锁也失败,那么只能升级成重量级锁。
3.可见偏向锁,轻量级锁,自旋锁都是乐观锁。

偏向锁的获取和撤销:

HotSpot作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入偏向锁。

线程1检查对象头中的Mark Word中是否存储了线程1,如果没有则CAS操作将Mark Word中的线程ID替换为线程1。此时,锁偏向线程1,后面该线程进入同步块时不需要进行CAS操作,只需要简单的测试一下Mark Word中是否存储指向当前线程的偏向锁,如果成功表明该线程已经获得锁。如果失败,则再需要测试一下Mark Word中偏向锁标识是否设置为1(是否是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将偏向锁指向当前线程

偏向锁的竞争结果:

根据持有偏向锁的线程是否存活

1.如果不活动,偏向锁撤销到无锁状态,再偏向到其他线程
2.如果线程仍然活着,则升级到轻量级锁

 

轻量级锁膨胀:

1.线程在执行同步块之前,JVM会在当前栈桢中创建用于存储锁记录的空间(Lock record),并将对象头中的Mark Word复制到锁记录中(Displaced Mark Word)。

2.然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针

3.如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程尝试使用自旋来获取锁。在自旋次数超过一定次数,则将 对象头 升级为 重量级锁,当前线程不再自旋,陷入阻塞。

轻量级锁的释放

用 CAS 操作 把 Lock Record 中的副本拷贝到 对象头的 MarkWord 中,如果替换成功,则整个同步过程就顺利完成了;如果替换失败,说明现在的锁已经是重量级锁了,说明有其他线程尝试获取过该锁,就要在释放锁的同时,唤醒被挂起的线程。

参考:Java中常用的锁机制(好文强推)

5、什么是逃逸分析

逃逸分析的基本行为就是分析对象的动态作用域。当一个对象在方法中被定义后,它可能被外部方法所引用(例如作为形参传递到其它方法中去),称为方法逃逸。如果是被外部线程访问到,称为线程逃逸。如果能够证明一个对象不会逃逸到方法或者线程之外,则可能对这个对象进行一些高效的优化:

  • 栈上分配
    如果能够确定一个对象不会逃逸到方法之外,可以在栈上分配对象的内存,这样对象占用的内存空间可以随着栈帧出栈而销毁,减少gc的压力;
  • 同步消除
    如果逃逸分析得出对象不会逃逸到线程之外,那么对象的同步措施可以消除。
  • 标量替换
    如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆解,那么程序执行的时候可能不创建这个对象,改为在栈上分配这个方法所用到的对象的成员变量。

常见的发生逃逸的场景有:

给全局变量赋值,方法返回值,实例引用作为参数传递
参考:即时编译(JIT)

6、AQS

AQS介绍

AbstractQueuedSynchronizer:抽象同步队列,简称AQS。AQS是JDK下提供的一套用于实现基于FIFO等待队列的阻塞锁和相关的同步器的一个同步框架。主要依赖一个 int 成员变量 state 来表示同步状态,以及一个管理等待锁的线程的 CLH 等待队列
AQS的等待队列是一个CLH(Craig, Landin, and Hagersten lock queue)队列:竞争资源同一时间只能被一个线程访问, CLH为管理等待锁的线程的队列
synchronized 能够对一个需要确保线程安全的对象、方法实现多线程并发控制,这是在java语法层次的实现,而AbstractQueuedSynchronizer 则是在应用层次而不是语法层次(更高的层次)提供了实现多线程并发控制组件的基础。
可见 CountDownLatch 是基于AQS框架来实现的一个同步器.类似的同步器在JUC下还有不少。(eg. Semaphore )
一. AQS 是构建同步器的【框架】【核心思想】:线程请求资源情况
1:资源空闲则请求线程设置为工作线程,资源上锁情况
2:资源被占用则请求线程阻塞,加入CLH队列。等待资源空闲时竞争资源
二. AQS 定义两种资源共享模式
1. 独占锁 Exclusive :锁只能被一个线程占有例如: ReentrantLock 又分为公平锁和非公平锁
2. 共享锁 shared :多个线程共享锁例如: CountDownLatch 、Semaphore 三. AQS框架自定义模块尝试获取
/释放独占资源 tryAcquire() tryRelease() 尝试获取/释放共享资源 tryAcquireShared() tryReleaseShared() 四. AQS 常见组件1. ReentrantLock A 线程调用 lock()方法若state=0,则资源空闲,state++,且 A线程可重复获取锁若state!=0,则资源被占有,当state=0时其他线程才能竞争2. CountDownLatch (1) 构造器初始化【state = N】当【子线程】调用countDown(),通过 CAS操作state自减1当state=0时,调用await的线程恢复正常继续执行 只有达到一定数量的线程,才能突破关卡,继续运行3. CyclicBarrier 构造方法state=n 每当一个线程调用 await()方法,则CAS操作state自减1当state=0时,所有调用await()的线程恢复 好比是所有线程约定一起出去玩,直到所有线程都到了才可以出发

AQS源码

1. aquire()
    
    public void aquire(){   
        if(!tryAcquire()    // 尝试获取一次  
            && acquireQueued(addWaiter(Node.EXCLUSIVE),arg)) 
                // acquireQueued 【作用】:自旋检测  (tryAcquire()&& node==head.next)
                // addWaiter【作用】:添加当前线程node至队列尾部
                
            selfInterrupt();
        }
    【问题】:为何不仅调用 acuqireQueued(addWaiter())
        优先尝试最可能成功的代码,可减少执行的字节码指令

jdk中哪种数据结构或工具可以实现当多个线程到达某个状态时执行一段代码,栅栏和闭锁的区别

CountDownLatch 和 CyclicBarrier

CountDownLatch 

又称为闭锁,是一个同步辅助类,允许一个或者多个线程等待某个事件的发生, 事件没有发生前,所有线程将阻塞等待;而事件发生后,所有线程将开始执行;
维护了一个计数器 cnt,每次调用 countDown() 方法会让计数器的值减 1,减到 0 的时候,那些因为调用 await() 方法而在等待的线程就会被唤醒,继续执行后面的代码。
应用场景:
    确保某个计算在其需要的所有资源都被初始化之后才继续执行。二元闭锁(包括两个状态)可以用来表示“资源R已经被初始化”,而所有需要R的操作都必须先在这个闭锁上等待。
    确保某个服务在其依赖的所有其他服务都已经启动之后才启动。
    等待直到某个操作的所有参与者都就绪才继续执行。(例如:多人游戏中需要所有玩家准备才能开始)

CyclicBarrier

用来控制多个线程互相等待,只有当所有线程都到达时,这些线程才会继续执行。
和 CountdownLatch 相似,都是通过维护计数器来实现的。线程执行 await() 方法之后计数器会减 1,并进行等待,直到计数器为 0,所有调用 await() 方法而在等待的线程才能继续执行。

区别:

闭锁用于所有线程等待一个外部事件的发生,比如只有达到一定数量的线程,才能突破关卡,继续运行;栅栏则是所有线程相互等待,好比是所有线程约定一起出去玩,直到所有线程都到了才可以出发。直到所有线程都到达某一点时才打开栅栏(可以理解为一个内部事件),然后线程可以继续执行。
它们的另一个区别是,CyclicBarrier 的计数器通过调用 reset() 方法可以循环使用,所以它才叫做循环屏障。

如何使用信号量实现上述情况

7、ThreadLocal的原理(下面只是简单概括,详细原理查看《ThreadLocal原理,内存泄漏问题,怎么解决》)

每个Thread类中有一个ThreadLocalMap对象,这个ThreadLocalMap底层是一个键值对数组,每个键值对的键是ThreadLocal引用,值是我们要存储的数据对象。当我用调用ThreadLcoal对象的set()方法时, ThreadLocal对象会获取到当前当前线程的引用,根据这个引用获取到线程的成员ThreadLocalMap对象,然后后调用ThreadLocalMap对象的set方法存储到这个Map中。看似我们是把数据存储在了ThreadLcoal对象中,但是实际上我们是把数据存储在当前线程的ThreadLocalMap中。ThreadLocal的get()方法也是类似,先获取当前线程对象引用,然后获取这个线程的成员对象ThreadLocalMap,以 ThreadLocal 引用为键,取出这个键值对中的值。
因为每个健在ThreadMap中是唯一的,它唯一标识了一个健值对,所以我们在ThreadLocalMap中不能存储多个健相等的键值对,而因为这个ThreadLocalMap是以ThreadLocal对象引用为健值,所以一个ThreadLocalMap对象只能存储一个以同一个ThreadLocal对象引用为键值的键值对,也就是每个线程对同一个ThreadLocal对象,只能存储一个数据对象。

8、为什么有了lock之后synchronized没被废弃掉,反而进行了锁的优化

在解决死锁问题的时候,提出了一个破坏不可抢占条件方案,但是这个方案 synchronized 没有办法解决。原因是 synchronized 申请资源的时候,如果申请不到,线程直接进入阻塞状态了,而线程进入阻塞状态,啥都干不了,也释放不了线程已经占有的资源。但我们希望的是:

对于“不可抢占”这个条件,占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源,这样不可抢占这个条件就破坏掉了。

如果我们重新设计一把互斥锁去解决这个问题,那该怎么设计呢?我觉得有三种方案。
  1. 能够响应中断。synchronized 的问题是,持有锁 A 后,如果尝试获取锁 B 失败,那么线程就进入阻塞状态,一旦发生死锁,就没有任何机会来唤醒阻塞的线程。但如果阻塞状态的线程能够响应中断信号,也就是说当我们给阻塞的线程发送中断信号的时候,能够唤醒它,那它就有机会释放曾经持有的锁 A。这样就破坏了不可抢占条件了。
  2. 支持超时。如果线程在一段时间之内没有获取到锁,不是进入阻塞状态,而是返回一个错误,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。
  3. 非阻塞地获取锁。如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线程也有机会释放曾经持有的锁。这样也能破坏不可抢占条件。

这三种方案可以全面弥补 synchronized 的问题。到这里相信你应该也能理解了,这三个方案就是“重复造轮子”的主要原因,体现在 API 上,就是 Lock 接口的三个方法。详情如下:

// 支持中断的 API
void lockInterruptibly() throws InterruptedException;
// 支持超时的 API
boolean tryLock(long time, TimeUnit unit)throws InterruptedException;
// 支持非阻塞获取锁的 API
boolean tryLock();
synchronized 底层,先讲下面的,然后将锁的优化
Sychronized 修饰代码块 || 方法1.修饰代码块时通过【monitorenter 和 monitorExit 两条指令】,
分别指定同步代码块的开始位置和结束位置。
线程获取锁 = 获取位于对象头的monitor的持有权获取到锁,则计数器++。执行到monitorExit,则计数器-- 2.修饰方法 JVM通过 ACC_SYNCHRONIZED 辨别方法为同步方法 【面试口头】 Sychronized 是【JVM】层面的关键字。它是通过【字节码指令】实现的。 (1) Sychronized 修饰【代码块】时,montior-enter monitor-exit两个字节码指令表明 同步块的开始和结束位置。 (2) Sychronized 修饰【方法】时,JVM中通过ACC_SYCHRONIZED 标志同步方法

9、Lock和Condition

在并发编程领域,有两大核心问题:一个是互斥,即同一时刻只允许一个线程访问共享资源;另一个是同步,即线程之间如何通信、协作。这两大问题,管程都是能够解决的。Java SDK 并发包通过 Lock 和 Condition 两个接口来实现管程,其中 Lock 用于解决互斥问题,Condition 用于解决同步问题
支持超时、非阻塞、可中断的方式获取锁,这三种方式为我们编写更加安全、健壮的并发程序提供了很大的便利。
ReentrantLock 和 Sychronized 区别
1.两者都是【可重入锁】 : 
        外层方法获得锁之后,内层方法如果获取的是同一把锁,则可以直接获取锁,无需阻塞,这样一定程度上可以
    避免死锁问题2. Sychronized 依赖JVM实现,而ReentrantLock 依赖API实现(JDK层面)
        ReentrantLock 调用 lock() unlock() try/finally语句实现同步块,可以直接查看源代码
        Sychronized 在JVM层面,通过字节码指令 monitorEnter monitorExit指定同步块的开始和结束位置3. ReentrantLock 实现高级功能
        (1) ReentrantLock实现等待可中断:通过调用 lockInterruptibly() 中断等待锁的线程
        (2) ReentrantLock可实现公平锁,而Sychronized仅实现非公平锁:公平锁 = 先等待的线程,先获得锁
        (3) 等待/通知机制不同:
            Sychronized 通过 notiy() notifyAll() wait() 实现等待/通知机制
            ReentrantLock 通过 Condition对象实现。一个lock可创建多个Condition对象,一个Condition对象可注册多个线程。
                Condition 对象调用signal ||signalAll() 
                    唤醒线程所在范围 =  注册的线程,而Sychronized 调用 notify() || notifyAll() 
                    唤醒线程 = JVM选择的因此 ReentrantLock的等待通知机制更加灵活

10、Thread的start方法和run方法的区别?

 run方法就是普通的一个方法,代码运行在当前主线程,start会启动一个新的线程,并运行run方法。
posted @ 2020-12-17 20:57  Lucky小黄人^_^  阅读(353)  评论(0编辑  收藏  举报