并发编程面试题5

简介: 并发编程面试题5

synchronized 的作用?说说自己是怎么使用 synchronized 关键字

synchronized 的作用?

在 Java 中,synchronized 关键字是用来控制线程同步的,就是在多线程的环境下,控制 synchronized 代码段不被多个线程同时执行。


另外,在 Java 早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的 synchronized 效率低的原因。庆幸的是在 Java 6 之后 Java 官方对从 JVM 层面对synchronized 较大优化,所以现在的 synchronized 锁效率也优化得很不错了。在 Java 6 之后优化 synchronized 的实现方式,使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,从而减低了锁带来的性能消耗。


说说自己是怎么使用 synchronized 关键字?

synchronized关键字最主要的三种使用方式:

1、修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁


2、修饰静态方法: 也就是给当前类加锁,会作用于类的所有对象实例,因为静态成员不属于任何一个实例对象,是类成员( static 表明这是该类的一个静态资源,不管new了多少个对象,只有一份)。所以如果一个线程A调用一个实例对象的非静态synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。


3、修饰代码块: 指定加锁对象,对指定对象加锁,进入同步代码块前要获得指定对


象的锁。


总结: synchronized 关键字加到 static 静态方法是给 Class 类上锁。synchronized 关键字加到实例方法上是给对象实例上锁。尽量不要使用 synchronized(String a) 因为JVM中,字符串常量池具有缓存功能!


volatile 修饰符的有过什么实践?单例模式了解吗?来给我手写一下!给我解释一下双重检验锁方式实现单例模式的原理?

volatile 的实践最经典的就是单例模式

双重校验锁实现对象单例(线程安全)


另外,需要注意 uniqueInstance 采用 volatile 关键字修饰也是很有必要,因为使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行


uniqueInstance = new Singleton();这段代码其实是分为三步执行:


1、为 uniqueInstance 分配内存空间


2、初始化 uniqueInstance


3、将 uniqueInstance 指向分配的内存地址


但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时 T2 调用getUniqueInstance() 后发现 uniqueInstance 不为空,因此返回uniqueInstance,但此时 uniqueInstance 还未被初始化。


使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。


什么是自旋?什么是自旋锁?

什么是自旋?

我们了解什么叫自旋?“自旋”可以理解为“自我旋转”,这里的“旋转”指“循环”,比如 while 循环或者 for 循环。“自旋”就是自己在这里不停地循环,直到目标达成。而不像普通的锁那样,如果获取不到锁就进入阻塞。


对比自旋和非自旋的获取锁的流程

我们用这样一张流程图来对比一下自旋锁和非自旋锁的获取锁的过程。


我们来看自旋锁,它并不会放弃 CPU 时间片,而是通过自旋等待锁的释放,也就是说,它会不停地再次地尝试获取锁,如果失败就再次尝试,直到成功为止。


我们再来看下非自旋锁,非自旋锁和自旋锁是完全不一样的,如果它发现此时获取不到锁,它就把自己的线程切换状态,让线程休眠,然后 CPU 就可以在这段时间去做很多其他的事情,直到之前持有这把锁的线程释放了锁,于是 CPU 再把之前的线程恢复回来,让这个线程再去尝试获取这把锁。如果再次失败,就再次让线程休眠,如果成功,一样可以成功获取到同步资源的锁。


可以看出,非自旋锁和自旋锁最大的区别,就是如果它遇到拿不到锁的情况,它会把线程阻塞,直到被唤醒。而自旋锁会不停地尝试。


自旋锁的好处

阻塞和唤醒线程都是需要高昂的开销的,如果同步代码块中的内容不复杂,那么可能转换线程带来的开销比实际业务代码执行的开销还要大。


在很多场景下,可能我们的同步代码块的内容并不多,所以需要的执行时间也很短,如果我们仅仅为了这点时间就去切换线程状态,那么其实不如让线程不切换状态,而是让它自旋地尝试获取锁,等待其他线程释放锁,有时我只需要稍等一下,就可以避免上下文切换等开销,提高了效率。


用一句话总结自旋锁的好处,那就是自旋锁用循环去不停地尝试获取锁,让线程始终处于 Runnable 状态,节省了线程状态切换带来的开销。


缺点


那么自旋锁有没有缺点呢?

其实自旋锁是有缺点的。它最大的缺点就在于虽然避免了线程切换的开销,但是它在避免线程切换开销的同时也带来了新的开销,因为它需要不停得去尝试获取锁。如果这把锁一直不能被释放,那么这种尝试只是无用的尝试,会白白浪费处理器资源。也就是说,虽然一开始自旋锁的开销低于线程切换,但是随着时间的增加,这种开销也是水涨船高,后期甚至会超过线程切换的开销,得不偿失。


适用场景

所以我们就要看一下自旋锁的适用场景。首先,自旋锁适用于并发度不是特别高的场景,以及临界区比较短小的情况,这样我们可以利用避免线程切换来提高效率。


可是如果临界区很大,线程一旦拿到锁,很久才会释放的话,那就不合适用自旋锁,因为自旋会一直占用 CPU 却无法拿到锁,白白消耗资源。


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

在Java中,锁共有4种状态,级别从低到高依次为:无状态锁,偏向锁,轻量级锁和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级。


骚戴理解:刚开始如果没有synchronized锁没有线程占用那就是无状态锁,然后有个A线程占用了这个锁就会升级为偏向锁,然后如果这个时候A线程又来尝试获取这个锁,那就可以直接获取这个锁,如果是B线程来获取这个锁,那就会从偏向锁升级为轻量级锁, 轻量级锁认为虽然竞争是存在的,但是理想情况下竞争的程度很低,通过自旋方式等待一会儿上一个线程就会释放锁,B线程就会自旋等待A线程释放锁,这个时候如果又来了个C线程,那就会从轻量级锁升级为重量级锁,重量级锁会使除了此时拥有锁的线程以外的线程都阻塞。简单来说就是一个线程占有锁那就是偏向锁,两个就是轻量级锁,三个及以上就是重量级锁!


synchronized 锁升级原理

在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候 threadid 为空,jvm 让其持有偏向锁(就是先让它是一个偏向锁),并将 threadid 设置为其线程 id,再次进入的时候会先判断 threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,偏向锁就会升级为轻量级锁,轻量级锁认为虽然竞争是存在的,但是理想情况下竞争的程度很低,通过自旋方式等待一会儿上一个线程就会释放锁,但是当自旋超过了一定次数,或者一个线程持有锁,一个线程在自旋,又来了第三个线程访问时(反正就是竞争继续加大了),轻量级锁就会膨胀为重量级锁,重量级锁就是Synchronized,重量级锁会使除了此时拥有锁的线程以外的线程都阻塞。


i、重量级锁


重量级锁是一种称谓: 这种依赖于操作系统 Mutex Lock来实现的锁称为重量级锁。synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的,监视器锁本身依赖底层的操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致用户态和内核态两个态之间来回切换,对性能有较大影响。。为了优化synchonized,引入了轻量级锁,偏向锁。


Java中的重量级锁: synchronized


iii、轻量级锁


轻量级锁是JDK6时加入的一种锁优化机制: 轻量级锁认为虽然竞争是存在的,但是理想情况下竞争的程度很低,通过自旋方式等待一会儿上一个线程就会释放锁。轻量级锁是使用CAS操作去消除同步使用的互斥量,减少传统的重量级锁使用操作系统互斥量产生的性能消耗,轻量级是相对于使用操作系统互斥量来实现的重量级锁而言的。如果出现两个以上的线程争用同一个锁的情况(这里注意是两个以上!不是两个!),那轻量级锁将不会有效,必须膨胀为重量级锁。


优点: 如果竞争的程度很低(小于等于两个线程竞争锁),通过CAS操作成功避免了使用互斥量的开销。


缺点: 如果两个以上的线程竞争锁就会失效,除了互斥量本身的开销外,还额外产生了CAS操作的开销,因此在有竞争的情况下,轻量级锁比传统的重量级锁更慢。


iii、偏向锁


研究发现大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了不让这个线程每次获得锁都需要CAS操作的性能消耗,就引入了偏向锁。当一个线程访问对象并获取锁时,会在对象头里存储锁偏向的这个线程的ID,以后该线程再访问该对象时只需判断对象头的Mark Word里是否有这个线程的ID,如果有就不需要进行CAS操作,这就是偏向锁。


优点: 把整个同步都消除掉,连CAS操作都不去做了,优于轻量级锁。


缺点: 如果程序中大多数的锁都总是被多个不同的线程访问,那偏向锁就是多余的。


锁的升级的目的

锁升级是为了减低了锁带来的性能消耗。在 Java 6 之后优化synchronized 的实现方式,使用了偏向锁升级为轻量级锁再升级到重量级锁的方式,从而减低了锁带来的性能消耗。

线程 B 怎么知道线程 A 修改了变量

  • volatile 修饰变量
  • synchronized 修饰修改变量的方法
  • wait/notify
  • while 轮询


当一个线程进入一个对象的 synchronized 方法 A 之后,其它线程是否可进入此对象的 synchronized 方法 B?为什么?

不能

为什么?

其它线程只能访问该对象的非同步方法,同步方法则不能进入。因为非静态方法上的 synchronized 修饰符要求执行方法时要获得对象的锁,如果已经进入A 方法说明对象锁已经被取走,那么试图进入 B 方法的线程就只能在等锁池(注意不是等待池哦)中等待对象的锁。


synchronized、volatile、CAS、Lock 比较?

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

volatile 提供多线程共享变量可见性和禁止指令重排序优化。

CAS 是基于冲突检测的乐观锁(非阻塞)

Lock锁,使用时需要手动获取锁和释放锁,比synchronized更加灵活;

synchronized、volatile、CAS、Lock 比较?

场景

CAS:单个变量支持比较替换操作,如果实际值与期望值一致时才进行修改

volatile:单个变量并发操作,直接修改为我们的目标值

synchronized:一般性代码级别的并发

Lock:代码级别的并发,需要使用锁实现提供的独特机制,例如:读写分离、分段、中断、共享、重入等 synchronized 不支持的机制。

原子性


CAS:保证原子性

volatile:单个操作保证原子性,组合操作(例如:++)不保证原子性

synchronized:保证原子性

Lock:保证原子性

并发粒度


CAS:单个变量值

volatile:单个变量值

synchronized:静态、非静态方法、代码块

Lock:代码块

编码操作性


CAS:调用 JDK 方法

volatile:使用关键字,系统通过屏障指令保证并发性

synchronized:使用关键字,加锁解锁操作系统默认通过指令控制

Lock:手动加锁解锁

线程阻塞


CAS:不会

volatile:不会

synchronized:可能会

Lock:可能会

性能


CAS:主要表现在 CPU 资源占用

volatile:性能较好

synchronized:性能一般(JDK 1.6 优化后增加了偏向锁、轻量级锁机制)

Lock:性能较差


synchronized 和 Lock 有什么区别?

synchronized是Java内置关键字,在JVM层面,Lock是个Java类,是API层面的

synchronized 可以给类、方法、代码块加锁,而 lock 只能给代码块加锁

synchronized 不需要手动获取锁和释放锁,使用简单,发生异常会自动释放锁,不会造成死锁;而 lock 需要自己加锁和释放锁,如果使用不当没有 unLock()去释放锁就会造成死锁。

通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到。


synchronized和ReentrantLock问这两种锁有哪些区别?

① 底层实现上来说

ynchronized 是JVM层面的锁,是Java关键字,通过monitor对象来完成(monitorenter与monitorexit),ReentrantLock 是从jdk1.5以来(java.util.concurrent.locks.Lock)提供的API层面的锁。


synchronized 的实现涉及到锁的升级,具体为无锁、偏向锁、自旋锁、向OS申请重量级锁,ReentrantLock实现则是通过利用CAS(CompareAndSwap)自旋机制保证线程操作的原子性和通过volatile保证数据可见性以实现锁的功能。


② 是否可手动释放


synchronized 不需要用户去手动释放锁,synchronized 代码执行完后系统会自动让线程释放对锁的占用; ReentrantLock则需要用户去手动释放锁,如果没有手动释放锁,就可能导致死锁现象。一般通过lock()和unlock()方法配合try/finally语句块来完成,使用释放更加灵活。


③ 是否可中断


synchronized是不可中断类型的锁,除非加锁的代码中出现异常或正常执行完成; ReentrantLock则可以中断,可通过trylock(long timeout,TimeUnit unit)设置超时方法或者将lockInterruptibly()放到代码块中,调用interrupt方法进行中断。


④ 是否公平锁


synchronized为非公平锁


ReentrantLock则即可以选公平锁也可以选非公平锁,通过构造方法new ReentrantLock时传入boolean值进行选择,为空默认false非公平锁,true为公平锁。


⑤ 锁的对象


synchronzied锁的是对象,锁是保存在对象头里面的,根据对象头数据来标识是否有线程获得锁/争抢锁;ReentrantLock锁的是线程,根据进入的线程和int类型的state标识锁的获得/争抢。


synchronized 和 volatile 的区别是什么?

synchronized 表示只有一个线程可以获取作用对象的锁,执行代码,阻塞其他线程。

volatile 表示变量在 CPU 的寄存器中是不确定的,必须从主存中读取。保证多线程环境下变量的可见性;禁止指令重排序。


synchronized 和 volatile 的区别是什么?

volatile 是变量修饰符;synchronized 可以修饰类、方法、代码块。

volatile 仅能实现变量的修改可见性,不能保证原子性;而synchronized 则可以保证可见性和原子性。

volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。

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


补充

synchronized关键字在JavaSE1.6之后进行了主要包括为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁以及其它各种优化之后执行效率有了显著提升, 实际开发中使用 synchronized 关键字的场景还是更多一些。


volatile本质是在告诉JVM当前变量在寄存器中的值是不确定是不是最新值,使用前,需要先从主存中读取,因此可以实现可见性。而对n=n+1,n++等操作时,volatile关键字将失效,不能起到像synchronized一样的线程同步(原子性)的效果。


Java 中能创建 volatile 数组吗?

能,Java 中可以创建 volatile 类型数组,不过只是一个指向数组的引用,而不是整个数组。意思是,如果改变引用指向的数组,将会受到 volatile 的保护,但是如果多个线程同时改变数组的元素,volatile 标示符就不能起到之前的保护作用了。


volatile 变量和 atomic 变量有什么不同?

volatile 则是保证了所修饰的变量的可见。因为 volatile 只是在保证了同一个变量在多线程中的可见性,所以它更多是用于修饰作为开关状态的变量,即 Boolean 类型的变量,但它并不能保证原子性,例如用 volatile 修饰 count 变量,那么 count++ 操作就不是原子性的。AtomicInteger 类提供的 atomic 方法可以让这种操作具有原子性如getAndIncrement()方法会原子性的进行增量操作把当前值加一,其它数据类型和引用变量也可以进行相似操作。

volatile 多用于修饰类似开关类型的变量、Atomic 多用于类似计数器相关的变量、其它多线程并发操作用 synchronized 关键字修饰。


volatile 能使得一个非原子操作变成原子操作吗?为什么?

关键字volatile的主要作用是使变量在多个线程间可见,但无法保证原子性,对于多个线程访问同一个实例变量需要加锁进行同步。虽然volatile只能保证可见性不能保证原子性,但用volatile修饰long和double可以保证其操作原子性,一个典型的例子是在类中有一个 long 类型的成员变量。如果你知道该成员变量会被多个线程访问,如计数器、价格等,你最好是将其设置为 volatile,这样这个成员变量就从一个非原子操作变成原子操作(因为对于64位的long和double,如果没有被volatile修饰,那么对其操作可以不是原子的。在操作的时候,可以分成两步,每次对32位操作。如果使用volatile修饰long和double,那么其读写都是原子操作 对于64位的引用地址的读写,都是原子操作)


什么是不可变对象?它对写并发应用有什么帮助?

不可变对象(Immutable Objects)即对象一旦被创建它的状态(属性值)就不能改变,反之即为可变对象(Mutable Objects)。


不可变对象的类即为不可变类(Immutable Class)。Java 平台类库中包含许多不可变类,如 String、基本类型的包装类、BigInteger 和 BigDecimal 等。


只有满足所有域都是 final 类型,并且它被正确创建(创建期间没有发生 this 引用的逸出)才是不可变对象


它对写并发应用有什么帮助?

不可变对象保证了对象的内存可见性,对不可变对象的读取不需要进行额外的同步手段,提升了代码执行效率。


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

Java Concurrency API 中的 Lock 接口(Lock interface)是什么?

整体上来说 Lock 是 synchronized 的扩展版,Lock 提供了可轮询的(tryLock 方法)、定时的(tryLock 带参方法)、可中断的(lockInterruptibly)、可多条件队列的(newCondition 方法)锁操作。另外 Lock 的实现类基本都支持非公平锁(默认)和公平锁,synchronized 只支持非公平锁(在大部分情况下,非公平锁是高效的选择),Lock锁使用完后需要手动释放锁,不然可能导致死锁的现象


深入了解:java 锁 Lock接口详解[通俗易懂]-Java架构师必看


对比同步它有什么优势?

  1. 支持公平锁,可以使锁更公平
  2. 可以使线程在等待锁的时候响应中断
  3. 可以让线程尝试获取锁,并在无法获取锁的时候立即返回或者等待一段时间
  4. 需要的是显示获取和释放锁,这就为获取和释放锁提供了更多的灵活性


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

乐观锁

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


乐观锁的实现方式

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


2、java 中的 Compare and Swap 即 CAS ,当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。 CAS 操作中包含三个操作数 —— 需要读写的内存位置(V)、进行比较的预期原值和拟写入的新值(B)。如果内存位置 V 的值与预期原值 A 相匹配,那么处理器会自动将该位置值更新为新值 B。否则处理器不做任何操作


悲观锁

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程),Java中synchronized和ReentrantLock等独占锁就是悲观锁思想的实现。


什么是CAS?

AS(Compare And Swap)比较并替换,是线程并发运行时用到的一种技术,其实就是一种有名的无锁算法,即不适用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步。


CAS是原子操作,保证并发安全,而不能保证并发同步,是非阻塞的、轻量级的乐观锁,因为是乐观锁所以CAS不会保证线程同步,乐观的认为在数据更新期间没有其他线程影响,由于CAS是CPU指令,我们只能通过JNI与操作系统交互,关于CAS的方法都在sun.misc包下Unsafe的类里,java.util.concurrent.atomic包下的原子类等通过CAS来实现原子操作。


因为是乐观锁,所以适用于写比较少的情况下(多读场景),即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的吞吐量。但如果是多写的情况,一般会经常发生冲突,这就会导致CAS算法会不断的进行retry,这样反倒是降低了性能,所以一般多写的场景下用悲观锁就比较合适。


CAS实现原理

CAS操作有三个操作数—— 内存位置的值(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置更新为新值。否则,处理器不做任何操作。

原子类例如AtomicInteger里的方法都很简单,我们看一下getAndIncrement方法

//该方法功能是Interger类型加1
public final int getAndIncrement() {
        //主要看这个getAndAddInt方法
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }
//var1 是this指针
//var2 是地址偏移量
//var4 是自增的数值,是自增1还是自增N
public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            //获取内存值,这是内存值已经是旧的,假设我们称作期望值E
            var5 = this.getIntVolatile(var1, var2);
            //compareAndSwapInt方法是重点,
            //var5是期望值,var5 + var4是要更新的值
            //这个操作就是调用CAS的JNI,每个线程将自己内存里的内存值M
            //与var5期望值E作比较,如果相同将内存值M更新为var5 + var4,否则做自旋操作
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
        return var5;
    }

解释一下getAndAddInt方法的流程:


假设有一下情景:


1.A、B两个线程

2.jvm主内存的值1,A、B工作内存的值为1(工作内存会拷贝一份主内存的值)

3.当前期望值为1,做加1操作

4.此时var5 = 1,var4 = 1;

1.A线程将var5与工作内存值M比较,比较var5是否等于1

2.如果相同则将工作内存值修改为var5 + var4 即修改为2并同步到内存,此时this + valueOffset指针里,示例变量value的值就是2,结束循环

3.如果不相同,则是B线程修改了主内存的值,说明B线程已经先于A线程做了加1操作,A线程没有更新成功需要继续循环,注意此时var5更新为新的内存值,假设当前的内存值是2,那么此时var5 = 2,var + var4 = 3,重复上述步骤直到成功(自旋),成功之后,内存地址中的值就改变为3


CAS优缺点

优点

非阻塞的轻量级的乐观锁,通过CPU指令实现,在资源竞争不激烈的情况下性能高,相比synchronized重量锁,synchronized会进行比较复杂的加锁、解锁和唤醒操作。


缺点


ABA问题:现有线程C、D,线程D将A修改为B后又修改为A,此时C线程以为A没有改变过,java的原子类AtomicStampedReference,通过控制变量值的版本号来保证CAS的正确性。具体解决思路就是在变量前追加上版本号,每次变量更新的时候把版本号加一,那么A - B - A就会变成1A - 2B - 3A。

自旋时间过长,消耗CPU资源,如果资源竞争激烈,多线程自旋长时间消耗资源


CAS 的会产生什么问题?

ABA 问题

现有线程C和线程D,线程D将A修改为B后又修改为A,此时C线程以为A没有改变过,java的原子类AtomicStampedReference,通过控制变量值的版本号来保证CAS的正确性。


具体解决思路就是在变量前追加上版本号,每次变量更新的时候把版本号加一,那么A - B - A就会变成1A - 2B - 3A。


循环时间长开销大

对于资源竞争严重(线程冲突严重)的情况,CAS 自旋的概率会比较大,从而浪费更多的 CPU 资源,效率低于 synchronized。


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

当对一个共享变量执行操作时,我们可以使用循环 CAS 的方式来保证原子操作,但是对多个共享变量操作时,循环 CAS 就无法保证操作的原子性,这个时候就可以用锁。


什么是死锁?

当线程 A 持有独占锁a,并尝试去获取独占锁 b 的同时,线程 B 持有独占锁 b, 并尝试获取独占锁 a 的情况下,就会发生 AB 两个线程由于互相持有对方需要的锁,而发生的阻塞现象,我们称为死锁。


产生死锁的条件是什么?

  • 互斥条件:所谓互斥就是进程在某一时间内独占资源。
  • 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  • 不剥夺条件:进程已获得资源,在末使用完之前,不能强行剥夺。
  • 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。


这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之 一不满足,就不会发生死锁。

有什么具体的办法可以避免死锁

死锁是不应该在程序中出现的,在编写程序时应该尽量避免出现死锁。下面有几种常见的方式用来解决死锁问题:


避免多次锁定。尽量避免同一个线程对多个 Lock 进行锁定。例如上面的死锁程序,主线程要对 A、B 两个对象的 Lock 进行锁定,副线程也要对 A、B 两个对象的 Lock 进行锁定,这就埋下了导致死锁的隐患。

具有相同的加锁顺序。如果多个线程需要对多个 Lock 进行锁定,则应该保证它们以相同的顺序请求加锁。比如上面的死锁程序,主线程先对 A 对象的 Lock 加锁,再对 B 对象的 Lock 加锁;而副线程则先对 B 对象的 Lock 加锁,再对 A 对象的 Lock 加锁。这种加锁顺序很容易形成嵌套锁定,进而导致死锁。如果让主线程、副线程按照相同的顺序加锁,就可以避免这个问题。

尝试使用定时锁,使用 lock.tryLock(timeout)来替代使用内部锁机制,该参数指定超过 timeout 秒后会自动释放对 Lock 的锁定,这样就可以解开死锁了。

银行家算法。银行家算法的核心思想是在进程提出资源申请时,先预判此次分配是否会导致系统进入不安全状态。如果会进入不安全状态,就暂时不答应这次请求,让该进程先阻塞等待。


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

死锁

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

活锁

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

活锁和死锁的区别

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


饥饿

线程处于饥饿是指不断有优先级高的线程占用资源,导致优先级低的线程一直无法获得所需要的资源,一直无法执行的状态。

死锁与饥饿的区别

线程处于饥饿是因为不断有优先级高的线程占用资源,当不再有高优先级的线程争抢资源时,饥饿状态将会自动解除。死锁没有外力的作用下是不会解除的

Java 中导致饥饿的原因

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


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


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


什么是AQS

AbstractQueuedSynchronizer(AQS)是JDK 1.5提供的一套用于构造同步器的框架,通过一个FIFO(先进先出)队列维护线程同步状态,实现类只需要继承AbstractQueuedSynchronizer该类,并重写指定方法(这些重写方法很简单,无非是对于共享资源state的获取和释放)即可实现一套线程同步机制,这个类在java.util.concurrent.locks包,使用AQS能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的ReentrantLock,FutureTask等等皆是基于AQS的。当然,我们自己也能利用AQS非常轻松容易地构造出符合我们自己需求的同步器。


AQS根据资源互斥级别提供了独占和共享两种资源访问模式(资源共享方式);同时其定义Condition结构提供了wait/signal等待唤醒机制。在JUC中,诸如ReentrantLock、CountDownLatch等都基于AQS实现。


AQS 原理分析

AQS维护了一个volatile int state变量和一个CLH(三个人名缩写)双向队列,当线程获取锁时,即试图对state变量做修改,如修改成功则获取锁;如修改失败则包装为节点挂载到CLH队列中,等待持有锁的线程释放锁并唤醒队列中的节点。


AQS核心思想是如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。


CLH(Craig,Landin,and Hagersten)队列是一个虚拟的双向队列(虚拟的双向队列即不存在队列实例,仅存在结点之间的关联关系)。AQS是将每条请求共享资源的线程封装成一个CLH锁队列的一个结点(Node)来实现锁的分配


AQS使用一个int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。AQS使用CAS对该同步状态进行原子操作实现对其值的修改。


状态信息通过protected类型的getState,setState,compareAndSetState进行操作


AQS 对资源的共享方式

AQS定义两种资源共享方式Exclusive(独占)和Share(共享)

Exclusive(独占)

只有一个线程能执行,如ReentrantLock。又可分为公平锁和非公平锁:

1、公平锁:按照线程在队列中的排队顺序,先到者先拿到锁

2、非公平锁:当线程要获取锁时,无视队列顺序直接去抢锁,谁抢到就是谁的

Share(共享)

多个线程可同时执行,如Semaphore/CountDownLatch。Semaphore、CountDownLatch、CyclicBarrier、ReadWriteLock 等等


不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在底层实现好了。


AQS底层使用了什么设计模式

同步器的设计是基于模板方法模式的,如果需要自定义同步器一般的方式是这样


(模板方法模式很经典的一个应用):


使用者继承AbstractQueuedSynchronizer并重写指定的方法。(这些重写方法很简单,无非是对于共享资源state的获取和释放)

将AQS组合在自定义同步组件中实现,并调用其模板方法,而这些模板方法会调用使用者重写的方法。

自定义同步器在实现的时候只需要实现共享资源state的获取和释放方式即可,至于具体线程等待队列的维护,AQS已经在底层实现好了。


自定义同步器时需要重写下面几个AQS提供的模板方法:


isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。

tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。

tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。

tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。

tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。

默认情况下,每个方法都抛出 UnsupportedOperationException。 这些方法的实现必须是内部线程安全的,并且通常应该简短而不是阻塞。AQS类中的其他方法都是final ,所以无法被其他类使用(因为继承不了),只有这几个方法可以被其他类使用


目录
相关文章
|
8月前
|
安全 算法 Java
去某东面试遇到并发编程问题:如何安全地中断一个正在运行的线程
一个位5年的小伙伴去某东面试被一道并发编程的面试题给Pass了,说”如何中断一个正在运行中的线程?,这个问题很多工作2年的都知道,实在是有些遗憾。 今天,我给大家来分享一下我的回答。
68 0
|
9月前
|
资源调度
JUC并发编程之同步器(Semaphore、CountDownLatch、CyclicBarrier、Exchanger、CompletableFuture)附带相关面试题
1.Semaphore(资源调度) 2.CountDownLatch(子线程优先) 3.CyclicBarrier(栅栏) 4.Exchanger(公共交换区) 5.CompletableFuture(异步编程)
104 0
|
2天前
|
机器学习/深度学习 数据采集 自然语言处理
2024年Python最新【python开发】并发编程(下),2024年最新字节跳动的面试流程
2024年Python最新【python开发】并发编程(下),2024年最新字节跳动的面试流程
2024年Python最新【python开发】并发编程(下),2024年最新字节跳动的面试流程
|
8天前
|
安全 Go 开发者
Golang深入浅出之-Go语言并发编程面试:Goroutine简介与创建
【4月更文挑战第22天】Go语言的Goroutine是其并发模型的核心,是一种轻量级线程,能低成本创建和销毁,支持并发和并行执行。创建Goroutine使用`go`关键字,如`go sayHello("Alice")`。常见问题包括忘记使用`go`关键字、不正确处理通道同步和关闭、以及Goroutine泄漏。解决方法包括确保使用`go`启动函数、在发送完数据后关闭通道、设置Goroutine退出条件。理解并掌握这些能帮助开发者编写高效、安全的并发程序。
24 1
|
8天前
|
调度 Python
Python并发编程模型:面试中的重点考察点
【4月更文挑战第14天】Python并发编程包括多线程、多进程和协程,常用于提高系统响应和资源利用率。多线程简单但受限于GIL;多进程可规避GIL,但通信开销大;协程适合IO密集型任务,学习成本较高。面试常见问题涉及并发并行概念、GIL影响、进程间通信同步及协程的异步IO理解。掌握并发模型的选择与应用,能有效提升面试表现。
27 0
|
8天前
|
Java Go 调度
Go语言并发编程原理与实践:面试经验与必备知识点解析
【4月更文挑战第12天】本文分享了Go语言并发编程在面试中的重要性,包括必备知识点和面试经验。核心知识点涵盖Goroutines、Channels、Select、Mutex、Sync包、Context和错误处理。面试策略强调结构化回答、代码示例及实战经历。同时,解析了Goroutine与线程的区别、Channel实现生产者消费者模式、避免死锁的方法以及Context包的作用和应用场景。通过理论与实践的结合,助你成功应对Go并发编程面试。
28 3
|
8天前
真实并发编程问题-1.钉钉面试题
真实并发编程问题-1.钉钉面试题
43 0
|
8天前
|
NoSQL Java 关系型数据库
2024最新500道Java高岗面试题:数据库+微服务 +SSM+并发编程+..
今天分享给大家的都是目前主流企业使用最高频的面试题库,也都是 Java 版本升级之后,重新整理归纳的最新答案,会让面试者少走很多不必要的弯路。同时每个专题都做到了详尽的面试解析文档,以确保每个阶段的读者都能看得懂。
|
7月前
全到哭!从面试到架构,阿里大佬用五部分就把高并发编程讲清楚了
不知道大家最近去面试过没有?有去面试过的小伙伴应该会知道现在互联网企业招聘对于“高并发”这块的考察可以说是越来越注重了。基本上你简历上有高并发相关经验,就能成为企业优先考虑的候选人。其原因在于,企业真正需要的是能独立解决问题的人才。每年面试找工作的人很多,技术水平也是高低不一,而并发编程却一直是让大家很头疼的事情,很多人总觉得自己似乎掌握了并发编程的知识,但实际在面试或者工作中,都会被它吊打虐哭。
113 0
|
8月前
|
缓存 安全 Java
Java并发编程必知必会面试连环炮
Java并发编程必知必会面试连环炮
117 0