多线程面试基础

Posted niwa

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了多线程面试基础相关的知识,希望对你有一定的参考价值。

1.1,线程和进程的区别

进程是程序的一次执行过程,是系统运行的基本单位,进程是动态的,从系统运行一个程序即创建,直到系统关闭。如在Windows系统上打开qq这个程序,即创建了进程,关闭了qq这个程序,则进程结束

线程与进程有点类似,线程是比进程更少的执行单位,一个进程在执行的过程中会产生多个线程。线程与进程不同的,多个同类的线程可以共享同一块资源,所以线程的上下文切换要比进程快

程序是含有指令和数据的文件,被储存在磁盘或其他的数据存储设备中

1.2 ,并发和并行

并发和并行很容易混淆,都可以指多个任务执行。但并发是偏重是多个任务交替执行,有时候会发生串行。而并行则是真正意义上的多任务同时执行。多线程在单核的CPU中,是交替运行的,在多核CPU中,CPU中的任务可以并行

 

1.3,线程的基本状态

①线程创建

②可运行:该状态的线程位于可运行的线程池中,等待被线程调度选中(操作系统会通过一些算法),获取CPU的使用权(锁)

③运行

④阻塞:线程由于有更优先级更高的线程,或者其他原因进入阻塞状态,释放(锁)CPU的使用权

  阻塞的状态:

    (1)等待阻塞:正在运行的线程执行wait()方法,JVM会把当前的线程放入等待队列中

    (2)同步阻塞:正在运行的线程在获取同步锁的时候,若该同步锁被其他线程占用,则JVM会把会把线程放入锁池中

    (3)其他阻塞:正在运行的线程执行sleep()或者join()时,或者发IO请求,JVM会把该线程设置为阻塞。直到睡眠时间结束,join等待超时,IO处理结束,才会被唤醒,重新执行

⑤死亡:线程的main()方法结束,run()方法结束,或者异常中断运行方法,则改线程结束生命周期。

技术图片

 

 

 

1.4,使用多线程的三种方式

(1)继承Thread类

  • 定义Thead子类并实现run()方法,run()是线程执行体
  • 创建此子类实例对象,即创建了线程对象
  • 调用线程对象的start()方法来启动线程

 

(2)实现Runable接口创建线程类

  • 定义Runable的实现类,重写run()方法作为线程执行体
  • 创建Runable实现类的实例对象,并将此实例对象作为Thread的targe再创建线程对象,此线程对象才是真正的子线程对象。
  • 调用线程对象的start()方法启动线程

(3)

使用Callable和Future创建线程

Callable接口

Callable接口特点,与Runable的区别

  • Callable类似于Runable的增强版,区别在于Callable是可以有返回值,并且可以抛出异常的。
  • Callable中有一个call()方法,可以作为线程的执行体,但是线程执行体不会被直接调用,因为无法直接获取子线程返回值,
  • Callable接口不是Runable接口,所以无法作为Thread的target来像Runable那样创建线程

基于以上三点,Future接口派上用场了,

Future接口

Future接口提供了一个FutherTask实现类,此实现类还实现了Runable接口,因此它的实例可以作为Thread类的target,与Callable结合使用从而实现多线程。

Future接口中有如下方法控制线程,

cancel(..)取消关联的Callable任务

get(..)获取关联的Callable钟call()方法的返回值,这里解决了Callable实现多线程但无法直接调用call()获取子线程返回值的问题

get(timeout, unit).

isCancelled()

isDone()

  • 使用Callable和Future创建线程的步骤如下,
  • 创建Callable的实现类,并实现call()方法作为线程执行体
  • 使用FutureTask类来包装Callable对象
  • 使用FutureTask类对象作为Thread的target来创建子线程
  • 调用FutureTask类对象的get()方法获取子线程结束后的返回值,此过程可以抛出异常

 

优缺点:

  ①使用继承Thread,编程简单,直接用this就可以访问当前线程,缺点是不够灵活

  ②Callable和Runable方式基本相同,扩展性强,灵活。缺点,编程相对复杂

  一般使用第三种,实现Future接口

 

1.6,线程池

线程池的好处,减少在创建和销毁线程所耗费的时间以及系统的资源开销,解决资源不足的问题。如果不使用线程池可能会导致系统创建大量的同类线程而导致内存消耗完或者“过度切换”问题

 

几种线程池

newCachedThreadPool

创建一个可缓存的线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可收回,则新建线程

这种类型的线程池特点是:

  • 工作线程的创建数量几乎没有限制(其实也有限制的,数目为Interger. MAX_VALUE), 这样可灵活的往线程池中添加线程。
  • 如果长时间没有往线程池中提交任务,即如果工作线程空闲了指定的时间(默认为1分钟),则该工作线程将自动终止。终止后,如果你又提交了新的任务,则线程池重新创建一个工作线程。
  • 在使用CachedThreadPool时,一定要注意控制任务的数量,否则,由于大量线程同时运行,很有会造成系统瘫痪

newFixedThreadPool

创建一个指定工作的线程池,每当提交一个任务就创建一个工作线程,如果工作线程的数量达到线程初始的数量,则将任务存入到池队列中

特点:

  • 当没有任务运行的时候,依然不会释放工作线程,占用一定的资源

newSingleThreadExecutor

创建一个单线程executor,即只创建唯一的工作者线程,只会用唯一的工作线程来执行任务,保证所有任务按指定的顺序执行(FIFO,LIFO,优先级)。如果这个工作者线程异常结束,则会有另一个线程取代,保证顺序执行

  特点: 

  • 保证是顺序地执行,在任意指定的时间内不会有多个线程活动

 ④newScheduleThreadPool

创建一个定长的线程池,支持定时的以及周期性的任务执行。

 

线程池的组成:

一般线程池分为4个部分组成:

①线程池管理器:用于创建并管理线程池

②工作线程:线程池中的线程

③任务接口:每个任务是实现的接口,用于工作线程的调度

④任务队列:用于存放待处理,提供一种缓冲机制

 

线程池的拒绝策略

当线程池的线程满了,等待队列也排满,再也装不下任务了的时,JDK的拒绝策略机制会合理解决这个问题:

①AbortPolicy:直接抛出异常,阻止系统继续运行

②CallerRunPolicy:线程池未关闭,该策略直接在调用者线程中,运行当前被丢弃的任务。任务提交线程的积极性会急剧下降

③DiscardOldestPolicy : 丢弃最老的一个请求,也就是即将被执行的一个任务,并尝试再次提交当前任务

④DiscardPolicy :丢弃无法处理的任务

 

在阿里巴巴的Java使用手册中强制不允许使用Executors来创建线程

原因:

  ①FixedThreadPool和SingleThreadPool,允许Integer.MAX_VALUE,可能会堆积大量的请求,导致OOM

  ②CacheThreadPool和ScheduleThreadPool,允许创建线程数量为Integer.MAX_VALUE ,可能会创建大量的线程,导致OOM

死锁与活锁的区别,死锁与饥饿的区别?

死锁:是指两个或两个以上的进程(或线程)在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。 

产生死锁的必要条件: 

  • 互斥条件:所谓互斥就是进程在某一时间内独占资源。

  • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。 

  • 不剥夺条件:进程已获得资源,在末使用完之前,不能强行剥夺。  

  • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

活锁:任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试,失败,尝试,失败。

活锁死锁的区别在于,处于活锁的实体是在不断的改变状态,所谓的“活”, 而处于死锁的实体表现为等待;活锁有可能自行解开,死锁则不能。

饥饿:一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行的状态。 

Java中导致饥饿的原因: 

  • 高优先级线程吞噬所有的低优先级线程的CPU时间。 

  • 线程被永久堵塞在一个等待进入同步块的状态,因为其他线程总是能在它之前持续地对该同步块进行访问。 

  • 线程在等待一个本身也处于永久等待完成的对象(比如调用这个对象的wait方法),因为其他线程总是被持续地获得唤醒。

Java Concurrency API中的Lock接口(Lock interface)是什么?对比同步它有什么优势?

Lock接口比同步方法和同步块提供了更具扩展性的锁操作。 

他们允许更灵活的结构,可以具有完全不同的性质,并且可以支持多个相关类的条件对象。

它的优势有:

  • 可以使锁更公平

  • 可以使线程在等待锁的时候响应中断

  • 可以让线程尝试获取锁,并在无法获取锁的时候立即返回或者等待一段时间

  • 可以在不同的范围,以不同的顺序获取和释放锁

整体上来说Lock是synchronized的扩展版,Lock提供了无条件的、可轮询的(tryLock方法)、定时的(tryLock带参方法)、可中断的(lockInterruptibly)、可多条件队列的(newCondition方法)锁操作。另外Lock的实现类基本都支持非公平锁(默认)和公平锁,synchronized只支持非公平锁,当然,在大部分情况下,非公平锁是高效的选择。

 

 

在Java中CycliBarriar和CountdownLatch有什么区别?

CyclicBarrier可以重复使用,而CountdownLatch不能重复使用。 

Java的concurrent包里面的CountDownLatch其实可以把它看作一个计数器,只不过这个计数器的操作是原子操作,同时只能有一个线程去操作这个计数器,也就是同时只能有一个线程去减这个计数器里面的值。 

你可以向CountDownLatch对象设置一个初始的数字作为计数值,任何调用这个对象上的await()方法都会阻塞,直到这个计数器的计数值被其他的线程减为0为止。 

所以在当前计数到达零之前,await 方法会一直受阻塞。之后,会释放所有等待的线程,await的所有后续调用都将立即返回。这种现象只出现一次——计数无法被重置。如果需要重置计数,请考虑使用 CyclicBarrier。 

CountDownLatch的一个非常典型的应用场景是:有一个任务想要往下执行,但必须要等到其他的任务执行完毕后才可以继续往下执行。假如我们这个想要继续往下执行的任务调用一个CountDownLatch对象的await()方法,其他的任务执行完自己的任务后调用同一个CountDownLatch对象上的countDown()方法,这个调用await()方法的任务将一直阻塞等待,直到这个CountDownLatch对象的计数值减到0为止

CyclicBarrier一个同步辅助类,它允许一组线程互相等待,直到到达某个公共屏障点 (common barrier point)。在涉及一组固定大小的线程的程序中,这些线程必须不时地互相等待,此时 CyclicBarrier 很有用。因为该 barrier 在释放等待线程后可以重用,所以称它为循环 的 barrier。

 

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

悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。再比如Java里面的同步原语synchronized关键字的实现也是悲观锁。

乐观锁:顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

乐观所和悲观锁的应用场景不一样。如果业务场景很少甚至不会发生冲突,可以使用乐观锁,加大系统的吞吐量,并节约锁的开销。如果是经常会发生冲突的,用乐观锁,上层应用会不断热retry,反而降低了性能。需要用悲观锁,避免数据的丢失更新

 乐观锁的实现方式: 

①使用版本标识来确定读到的数据与提交的数据是否一致。提交修改标识版本,不一致时可以采取丢弃再获取策略

②使用CAS算法(compare and swap),当多个线程使用CAS同时更新一个变量时,只有一个更新成功,其他都失败。失败的线程并不会被挂起,而是被告知竞争失败,并可以继续尝试。

CAS 操作中包含三个操作数 —— 需要读写的内存位置(V)、进行比较的预期原值(A)和拟写入的新值(B)。如果内存位置V的值与预期原值A相匹配,那么处理器会自动将该位置值更新为新值B。否则处理器不做任何操作。

CAS算法的缺点:

①ABA问题:如果一个值是A变成了B,后面继续更新为A,那么使用CAS去检查的时候发现它的值没有什么变化,但实际上已经发生了更新。解决这样的问题是在每一次更新的时候加一个版本号。

②循环时间长,开销大。当线程冲突严重的情况下,CAS自旋概率会比较大,从而浪费更多的CPU资源,效率低于synchronized。 

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

什么是线程安全问题

线程安全问题本质上就是原子性,可见性,有序性的问题

原子性:和数据库事务中一样,满足原子特性,操作不可中断。要么全部执行成功,要么全部执行失败。

有序性:编译器和处理器为了优化程序性能而对指令重排序,也就是你的代码顺序和最终执行的指令顺序是不一致的,重排序可能会导致多线程程序出现内存可见性的问题

可见性:多个线程访问同一个变量,其中一个变量对这个共享变量修改,其他线程能马上获取到修改后的值

 

什么是原子性问题

先理解CPU高速缓存。线程设计的目的是充分利用CPU达到实时性的效果,但是很多时候CPU的计算任务还需要和内存进行交互,比如读取内存中的运算数据、将处理结果写入到内存。在理想情况下,存储器应该是非常快速的执行一条指令,这样CPU就不会受到存储器的限制。但目前技术无法满足,所以就出现了其他的处理方式。

技术图片

 

存储器顶层是CPU中的寄存器,存储容量小,但是速度和CPU一样快,所以CPU在访问寄存器时几乎没有延迟;接下来就是CPU的高速缓存;最后就是内存。 

 技术图片

 

 高速缓存从下到上越接近CPU访问速度越快,同时容量也越小。现在的大部分处理器都有二级或者三级缓存,分别是L1/L2/L3, L1又分为L1-d的数据缓存和L1-i的指令缓存。其中L3缓存是在多核CPU之间共享的。

 

原子性问题就是:在多核CPU架构下,在同一时刻对同一共享变量执行 decl指令(递减指令,相当于i--,它分为三个过程:读->改->写,这个指令涉及到两次内存操作,那么在这种情况下i的结果是无法预测的。

 处理器是如何解决原子性问题

 (1)处理器使用总线锁,总线锁就是使用CPU提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞,那么该处理器就可以独占共享内存

总线锁开销很多,使得多个CPU是并行执行的变成串行执行,性能严重下降

(2)缓存锁

所谓缓存锁是指被缓存在处理器中的共享数据,在Lock操作期间被锁定,那么当被修改的共享内存的数据回写到内存时,处理器不在总线上声明LOCK#信号,而是修改内部的内存地址,并通过 缓存一致性机制来保证操作的原子性。

 

 什么是缓存一致性

 

就是多个CPU核心中缓存的同一共享数据的数据一致性,而(MESI)使用比较广泛的缓存一致性协议。MESI协议实际上是表示缓存的四种状态

  • M(Modify) 表示共享数据只缓存在当前CPU缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致

  • E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改

  • S(Shared) 表示数据可能被多个CPU缓存,并且各个缓存中的数据和主内存数据一致

  • I(Invalid) 表示缓存已经失效

每个CPU核心不仅仅知道自己的读写操作,也会监听其他Cache的读写操作 CPU的读取会遵循几个原则

  • 如果缓存的状态是I,那么就从内存中读取,否则直接从缓存读取

  • 如果缓存处于M或者E的CPU 嗅探到其他CPU有读的操作,就把自己的缓存写入到内存,并把自己的状态设置为S

  • 只有缓存状态是M或E的时候,CPU才可以修改缓存中的数据,修改后,缓存状态变为M

JAVA是如何实现原子操作的

(1)使用循环CAS实现原子操作

 JVM中的CAS是利用了处理器提供的CMPXCHG指令实现的。自旋CAS的实现基本思路就是循环进行CAS直到成功为止。CAS问题参照乐观锁实现

(2)使用锁机制。保证获得锁的线程才能操作锁定的内存区域

锁的升级和对比

在JAVA6开始,为了减少锁的获得和释放带来的性能消耗,引入了偏向锁,和轻量级锁。所以从JAVA6开始,锁就有4种状态,从低到高是:无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态。锁只能升级不能降级

 偏向锁:

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程I D,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark W ord里是存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则要再测试一下Mark W ord中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

 

偏向锁的获得:

先了解Java对象头,synchronized用的锁是存在Java对象头里的,如果对象是数组,Java虚拟机就会用3个字节宽(Word)存储对象头,如果是飞数组对象,会用2个字节宽存储对象头,如下图(图片来源《Java并发编程的艺术》):

技术图片

 

 对象头里的Mark Work里默认存储对象Hashcode,分代年龄,和锁标志位,如下图(图片来源《Java并发编程的艺术》)

技术图片

 

 运行期间,Mark Word的存储数据会随锁标志位变化而变化

技术图片

 

 在64位虚拟机下,Mark Word是64bit大小

技术图片

 

偏向锁的获得和撤销流程:

偏向锁使用一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁的时候,持有偏向锁的线程才会释放。偏向锁的撤销,需要等到全局安全点(这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查 持有偏向锁的线程是否活着。如果线程不处于活动状态,则将对象头设置为无锁状态;如果线程还活着,拥有偏向锁栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头Mark Word要么重新偏向于其他线程,要么恢复到无锁或者不标记不适合作为偏向锁,最后唤醒线程,如下图(图片来源《Java并发编程的艺术》)

技术图片

 

 偏向锁的关闭:

偏向锁在Java6以后是默认开启的,但是在应用程序启动后几秒内激活,可以设置JVM参数来关闭延迟-XX:BiasedLockingStartupDelay = 0.如果你确定应用程序上的所有锁都处于竞争情况下,可以使用JVM参数关闭偏向锁  -XX:-UseBiasedLocking=false,那么程序就会进入轻量级锁

 

轻量级锁

(1)轻量级锁加锁

线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Work复制到锁的记录中,然后线程尝试用CAS将对象头中的Mark Work替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,便会尝试通过自旋来获取锁

(2)轻量级锁解锁

解锁时会使用原子的CAS操作将Displace Mark Word替换到对象头,如果成功则表示没有发生竞争,如果失败,表示当前存在锁的竞争,锁就会膨胀为重量级锁如下图(图片来源《Java并发编程的艺术》)

技术图片

 

 

因为自旋会消耗CPU的资源,为了避免无用的自旋,比如获得锁的线程被阻塞,一旦升级为重量级锁,就不会再恢复到轻量级锁的状态。当锁处于这个状态下,其他线程试图获取锁的时候,都会被阻塞,直到持有锁的线程释放之后会唤醒这些线程,被唤醒的线程会进行下一轮的夺锁之争

 

锁的优缺点对比

 技术图片

 并发编程的可见性问题

CPU高速缓存以及指令重排序都会造成可见性问题

可见性参考什么是线程问题。

可见性:多个线程访问同一个变量,其中一个变量对这个共享变量修改,其他线程能马上获取到修改后的值

(1)MESI优化带来的可见性问题。

MESI协议,缓存一致性协议。这个协议存在一个问题,就是当CPU0修改当前缓存的共享数据时,需要发送一个消息给其他缓存了相同数据的CPU核心,这个消息传递给其他CPU核心以及收到消息完成各自缓存状态切换过程中,CPU会等待所有缓存响应完成,这样会降低处理器的性能。为了解决这个问题,引入Store Bufferes存储缓存。处理器把需要写入到主内存中的值先写入到存储缓存中,然后继续去处理其他指令。当所有的CPU核心返回了失效确认时,数据才会被最终提交。但是这种优化又会带来另外的问题。 如果某个CPU尝试将其他CPU占有的共享数据写入到内存,消息提交给store buffer以后,当前CPU继续做其他事情,而如果后面的指令依赖于这个被写入内存的最新数据(由于store buffer还没有写入到内存),就会产生可见性问题(也就是值还没有更新到内存中,这个时候读取到的共享数据的值是错误的)。

(2)Store Bufferes带来的CPU内存的乱序访问导致的可见性问题

 由于Store Bufferes 写入内存的不确定性,那就意味着这个过程的执行顺序不确定.如CPU0和CPU1分别在两个独立的CPU核心上执行,假如CPU0缓存了isFinsh这个共享变量,并且状态为E(独占),而value可能是被其他CPU核心修改后变成I(失效状态).这种情况下Value的缓存数据变更路径为, value将失效状态需要响应给触发缓存更新的CPU核心,接着该CPU将 StoreBufferes写入到内存,这就会导致value会比isFinish更迟的抛弃存储缓存。那么就可能出现CPU1读取到了isFinish的值为true,而value的值不等于10的情况。这种CPU的内存乱序访问,会带来可见性问题。

value = 3;

void exeToCPU0(){ 

  value = 10;

  isFinsh = true;}

void exeToCPU1(){if(isFinsh){assert value == 10;}}

 

如何解决可见性问题

由于缓存一致性带来的可见性问题,CPU层面提供了一个memory barrier(内存屏障).从硬件层面来看这个 memroy barrier就是CPU flush store bufferes中的指令。软件层面可以决定在适当的地方来插入内存屏障。

 

什么是内存屏障:

内存屏障就是将story bufferes 中的指令写入到内存,从而使得其他访问同一共享内存的线程可见性。 X86的memory barrier指令包括lfence(读屏障) sfence(写屏障) mfence(全屏障)。

   (1)写屏障(story Memory barrier):告诉处理器在写屏障之前的所有已经存储在存储缓存(story bufferes)中的数据同步到内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或写是可见的,也就是到写屏障之前,缓存数据就已经同步到主内存中

   (2)读屏障(Load Memory barrier):处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读写操作都是可见的  

      (3)全屏障(Full Memory barrier):确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作

例子:

value = 3;
void exeToCPU0(){
 value = 10;
 storeMemoryBarrier(); //这个是一个伪代码,插入一个写屏障,使得value=10这个值强制写入到主内存中
 isFinsh = true;
}
void exeToCPU1(){
 if(isFinsh){
   loadMemoryBarrier();//伪代码,插入一个读屏障,使得cpu1从主内存中获得最新的数据
   assert value == 10;
 }
}

 内存屏障是防止CPU对内存的乱序访问来保证共享数据在多线程执行下保证可见性

  并发编程的有序性问题

有序性简单来说就是程序代码执行的顺序是否按照我们编写代码的顺序执行,一般来说,为了提高性能,编译器和处理器会对指令做重排序,重排序分3类

  (1)编译器优化重排序,在不改变单线程程序语义的前提下,改变程序的执行顺序

  (2)指令集并行重排序,对于不存在数据依赖的指令下,处理器可以改变语句对应指令的执行顺序来充分利用CPU资源

  (3)内存系统重排序,CPU内存乱序访问问题

 所以,代码的最终执行如下图:

 技术图片

 

 有序性带来可见性问题,可以通过内存屏障指令来进行特定类型的处理器重排序

 从JMM(Java memory model,Java内存模型)层面是怎么解决线程并发问题的

  • 原子性:Java中提供了两个高级指令 monitorenter和 monitorexit,也就是对应的synchronized同步锁来保证原子性

  • 可见性:volatile、synchronized、final都可以解决可见性问题

  • 有序性:synchronized和volatile可以保证多线程之间操作的有序性,volatile会禁止指令重排序

硬件层面的原子性,可见性,和有序性在不同的CPU架构和操作系统中实现都可能不一样,而Java语言是once write,run anywhere。所以JVM层就需要屏蔽底层的差异,因此在JVM规范中定义了JMM

 

JAVA内存模型抽象结构

 

技术图片

           (JMM内存模型抽象图)

 

JMM属于语言级别的抽象内存模型,可以简单理解为对硬件模型的抽象,它定义了共享内存中多线程程序读写操作的行为规范,也就是在虚拟机中将共享变量存储到内存以及从内存中取出共享变量的底层细节。 通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了CPU多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。 需要注意的是,JMM并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序,也就是说在JMM中,也会存在缓存一致性问题和指令重排序问题。只是JMM把底层的问题抽象到JVM层面,再基于CPU层面提供的内存屏障指令,以及限制编译器的重排序来解决并发问题

 

Java内存模型定义了线程和内存的交互方式,在JMM抽象模型中,分为主内存、工作内存;主内存是所有线程共享的,一般是实例对象、静态字段、数组对象等存储在堆内存中的变量。工作内存是每个线程独占的,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量,线程之间的共享变量值的传递都是基于主内存来完成。

 

在JMM中,定义了8个原子操作来实现一个共享变量如何从主内存拷贝到工作内存,以及如何从工作内存同步到主内存,交互如下: 

 

8个原子操作指令

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

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

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

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

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

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

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

  • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

技术图片

volatile关键字

volatile可以理解为轻量级的synchronize。在多处理器开发中保证共享变量的可见性,可见性是指当一个线程修改共享变量时,另外一个线程能读到这个修改的值

volatile是如何保证可见性的:

Java的volatile操作,在JVM实现层面第一步是给予了C++的原语实现。c/c++中的volatile关键字,用来修饰变量,通常用于语言级别的 memory barrier。volatile修饰的变量,在汇编语言反编译下,首先判断CPU是否是多核,如果是单核则不存在内存不可见或者乱序问题。 Lock :汇编指令,lock指令会锁住操作的缓存行(cacheline), 一般用于read-Modify-write的操作;用来保证后续的操作是原子的 cc代表的是寄存器,memory代表是内存;这边同时用了”cc”和”memory”,来通知编译器内存或者寄存器内的内容已经发生了修改,要重新生成加载指令(不可以从缓存寄存器中取) 这边的read/write请求不能越过lock指令进行重排,那么所有带有lock prefix指令(lock ,xchgl等)都会构成一个天然的x86 Mfence(读写屏障),这里用lock指令作为内存屏障,然后利用asm volatile("" ::: "cc,memory")作为编译器屏障. 这里并没有使用x86的内存屏障指令(mfence,lfence,sfence),应该是跟x86的架构有关系,x86处理器是强一致内存模型。被volatile声明的变量表示随时可能发生变化,每次使用时,都必须从变量i对应的内存地址读取,编译器对操作该变量的代码不再进行优化。

storyload屏障是固定调用的方法

避免volatile写与后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面是否需要插入一个StoreLoad屏障。为了保证能正确实现volatile的内存语义,JMM在采取了保守策略:在每个volatile写的后面,或者在每个volatile读的前面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里可以看到JMM在实现上的一个特点:首先确保正确性,然后再去追求执行效率

总结:volatile是通过防止指令重排序来实现多线程对于共享内存的可见性 

 

摘自《Java并发编程的艺术》,《Java高级架构文章》

 

以上是关于多线程面试基础的主要内容,如果未能解决你的问题,请参考以下文章

java基础--31.多线程常见的面试题

JAVA多线程和并发基础面试问答

JAVA多线程和并发基础面试问答

JAVA多线程和并发基础面试问答

“面试不败计划”:Java多线程和并发基础面试问答

JAVA多线程和并发基础面试问答