锁机制

锁机制

1. 说说线程安全问题,什么是线程安全,如何保证线程安全

线程安全指的是当多线程同时访问同一代码,不会产生不确定的结果。在多线程环境下也不会出现数据不一致的情况出现。

如果要确保线程安全,有如下几种方法:

  1. 不跨线程共享变量
    线程共享的变量改为方法局部级变量
  2. 使状态变量为不可变的
    使用final修饰(将变量变为常量)
  3. 在任何访问状态变量的时候使用同步
    使用synchronized修饰方法,或使用同步代码块。
  4. 每个共享的可变变量都需要由唯一一个确定的锁保护。
    使用Lock锁。

《探索并发编程(二)------写线程安全的Java代码》

2. 重入锁的概念,重入锁为什么可以防止死锁

重入锁ReentrantLock是一种递归无阻塞的同步机制。它是一个可重入的互斥锁定Lock,具有与使用synchronized方法和语句所访问的隐式监视器锁定相同的一些基本行为和语义,但功能更强大。

ReentrantLock的锁资源以state状态描述,利用CAS则实现对锁资源的抢占,并通过一个CLH队列阻塞所有竞争线程,在后续则逐个唤醒等待中的竞争线程。Reentrantlock继承AQS完全从代码层面实现了Java的同步机制,相对于synchronized,更容易实现对各种锁的扩展。同时,AbstractQueuedSynchronizer中的Condition配合ReentrantLock使用,实现了wait/notify的功能。

可重入锁可以解决某些死锁因素。比如在递归调用中,如果方法被上锁,而这个锁不可重入,在第二次调用时就会无法获得锁,造成死锁。可重入锁时针对同一个线程的,为每一个锁关联一个获取计数器和一个所有者线程,当极速器为0的时候,这个锁就没有被任何线程持有。当线程请求一个未被持有的锁时,将被记录下锁的持有者,并将计数值自增1,如果同一线程再次获取这个锁,计数值再次自增1,而只有当计数值为0时,锁才会被释放。这样就允许一个线程可以重复进入这个锁所保护的代码块,而不会在多态或递增时造成死锁。

参考:
《可重入锁》
《深入分析ReentrantLock》

3. 产生死锁的四个条件(互斥、请求与保持、不剥夺、循环等待)

如果一个进程集合里面的每个进程都在等待这个集合中的其他一个进程(包括自身)才能继续往下执行,若无外力他们将无法推进,这种情况就是死锁,处于死锁状态的进程称为死锁进程。
产生死锁的原因

  1. 竞争资源。系统中供多个进程共享的资源的数目不足以满足全部进程的需要时,就会引起对诸资源的竞争而发生死锁现象。
  2. 进程推进顺序不当。进程在运行过程中,请求和释放资源的顺序不当,也同样会导致产生进程死锁。

要产生死锁,有四个必要条件:

  1. 互斥Mutaual exclusion。有资源在某一个时刻只能被分配给一个线程使用。
  2. 持有Hold And Wait。当请求的资源已被占用从而导致线程阻塞时,资源占用者不但不需要释放该资源,还可以继续请求更多的资源。
  3. 不可剥夺No preemption。线程获得到的互斥资源不可被强行剥夺,也就是只有资源占用者自己才可以释放资源。
  4. 环形等待Circular Wait。若干线程以不同的次序获取互斥资源,从而形成环形等待的局面,想象在由多个线程组成的环形链中,每个线程都在等待下一个线程释放它持有的资源。

在线程所需求的资源中存在有限资源,当线程无法获得所需求的资源被阻塞,但拥有者在无需释放资源的情况下还被允许请求更多的其他资源,而且只有这个持有者拥有着资源的释放权力,这些线程还形成了一个你等我释放,我等你释放的环形循环等待的情况下,就发生了死锁。这四个条件都是在前者的基础上逐次升级,最终导致了死锁的发生。

参考:
《“死锁”四个必要条件的合理解释》
《死锁产生的原因及四个必要条件》

4. 如何检查死锁(通过jConsole检查死锁)

当发觉可能产生了死锁时,我们可以通过如下几种办法来检测是否真的发生了死锁。

  1. Jconsole
    找到jdk的安装位置,在bin目录下找到Jconsole,运行exe文件后选择可能发生了死锁的进程,进入后在线程中寻找左下角的<检测死锁>按钮,然后在新出现的死锁试图中查看死锁发生的线程,可以看到这些线程它们分别持有了那些锁,而需要的锁又被谁持有。
  2. Jstack
    进入jdk安装目录,在目录下启动cmd,输入jps,即可产看到被怀疑产生死锁的进程的进程号。记住进程号,执行Jstack -l 进程号命名,就可以查看到死锁信息了。

《Java如何查看死锁?》

5. volatile 实现原理(禁止指令重排、刷新内存)

在JDK1.5之前,volatile因为它往往出人意料的结果而备受争议,在JDK1.5,这个问题解决之后,才成为一个非常关键的关键字。

如果要完全理解volatile关键字,我们必须理解内存模型和Java的内存模型。在这里就不做过多赘述了。需要了解的可以查看本题下方的参考资料链接。

volatile可以被理解为是轻量级的synchronized,他在多处理器开发中保证了共享变量的“可见性”。可见性指当一个线程修改一个共享变量时,另一个使用同样共享变量的线程可以读到这个被修改后的值。
Java官方对volatile定义如下:Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排它锁单独获取这个变量。Java语言提供了volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,Java线程内存模型将确保所有线程看到这个变量的值是一定的。
volatile变量修饰符如果使用恰当的话,它不会引起线程上下文的切换和调度,所以比synchronized的使用和执行成本更低。如果变量被声明了volatile关键字,则JVM在对volatile修饰的变量进行写操作时,会向处理器发送一条带有lock前缀的指令(汇编代码:0x01a3de1d: movb 0x0,(%esp);)。

处理器为了提高处理速度,都是先将系统内存的数据读到内部缓存中后再进行操作,而不是直接和内存进行通讯。但操作完成之后不知道何时回写到内存。如果执行了带有lock前缀的指令,在多核处理器下会引发两个操作:

  1. 将当前处理器缓存行的数据写回到系统内存
  2. 这个写回内存的操作会导致其他CPU缓存了该内存地址的数据无效。

所以当一个volatile变量更新时,所有缓存中缓存的这个变量数据都会无效化,当处理器需要对这个数据进行修改操作时,会强制重新从系统内存中把数据读到处理器缓存中。

同时请注意,在这个导致缓存无效化之前的写入操作,是被“上锁”的。多处理器环境中的LOCK#信号确保在该信号期间,处理器可以独占使用任何共享内存(信号会锁住总线,导致其他CPU不能访问总线,而不能访问总线,则意味着不能访问系统内存。)但目前最新的处理器,如果访问的内存区域已经缓存在处理器内部,则不会声明LOCK#信号。相反,会锁定这块内存区域的缓存并写回到内存,使用换从一致机制来确保修改的原子性,即缓存锁定。缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据

然而无论如何都请千万注意,volatile是一种轻量级的锁,它只能保证锁的可见性,但不保证锁的原子性。譬如自增操作就不是原子性操作,因此在使用volatile时,请确保对该变量的写操作是原子性的,不然就可能造成不可预期的结果。

参考:
《聊聊并发(一)深入分析Volatile的实现原理》
《Java并发编程:volatile关键字解析》
《Java中Volatile底层原理与应用》

6. synchronized 实现原理(对象监视器)

synchronized可以保证其所修饰的方法或代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性。

Java中每个对象都可以作为锁,这是synchronized实现同步的基础。

  • 普通同步方法,锁是当前实例对象
  • 静态同步方法,锁是当前类的class对象
  • 同步方法块,锁是括号内的对象。

利用Javap工具可以查看生成的class文件信息来分析synchronize实现。
同步代码块使用monitorenter和monitorexit执行实现,而同步方法(这里无法查看底层实现,需要了解JVM底层)依靠方法修饰符ACC_SYNCHRONIZED实现。

在同步代码块中,monitorenter指令插入到同步代码块的开始位置,monitorexit指令插入到同步代码块的结束位置。JVM需要保证每一个monitorenter都有一个monitorexit与之相对应。任何对象都有一个对象监视器monitor与之相关联,且当一个monitor被持有之后,将处于锁定状态。线程执行到monitorenter指令时,会尝试获取对象所对应的monitor所有权,即尝试获取对象的锁。而这也就保证了这个锁在被释放前只会被至多一个线程获取到。

而在同步方法中,synchronized方法则会被翻译成普通的方法调用和返回指令。在VM字节码层面并没有任何的特别指令来实现被synchronized修饰的方法,而是在Class文件的方法表中将该方法的access_flags字段中的synchronized标志位置1,表明该方法是同步方法或该对象所属的Class在JVM的内部对象表示Klass作为锁对象。(摘自:http://www.cnblogs.com/javaminer/p/3889023.html)

参考:
《【死磕Java并发】—–深入分析synchronized的实现原理》

7. synchronized 与 lock 的区别

两者区别如下:

  1. 存在层次方面:
    synchronized是Java的关键字,在JVM层面上,而Lock是util的concurrent包下的接口,拥有自己的实现类。
  2. 锁的释放
    synchronized会自动释放锁,当同步的代码执行完毕或异常时都会自动释放;而Lock自必须手动释放。
  3. 锁的获取
    对synchronized而言,如果a线程获得了锁,则b会一直等待下去;而Lock就不一定了,线程可以选择放弃等待而直接结束。
  4. 锁的判断
    synchronized无法判断,而Lock可以
  5. 锁类型
    synchronized可重入,不可终端,非公平;而Lock可重入,可判断,可选是否公平。
  6. 性能
    synchronized适合少量同步,而Lock则适合大量同步
  7. 底层实现
    synchronized是一种悲观锁,而Lock底层是CAS乐观锁。

参考:
《详解synchronized与Lock的区别与使用》
《lock和synchronized的同步区别与选择》

8. AQS同步队列

抽象的队列同步器AbstractQueuedSynchronizer是用来构建锁或者其他同步组件的基础框架。它使用了一个volatile int state成员变量表示同步状态,通过内置的一个FIFO(FirstInFirstOut先进先出)队列来完成资源获取线程的排队工作。

同步器依赖内部的同步队列(一个FIFO双线队列,其实就是一个双向链表)来完成同步状态的管理。当前线程获取同步状态失败时,同步器会将当前线程和等待状态等信息构造成为一个节点node加入到同步队列,同时阻塞当前线程。当同步状态释放的时候,会把首节点中的线程唤醒,使首节点的线程再次尝试获取同步状态。AQS是独占锁和共享锁的实现的父类。

AQS分为独占锁和共享锁两种。

  • 独占锁:锁在一个时间点只能被一个线程战友。根据锁的获取机制,又分为公平锁和非公平锁。等待队列中按照FIFO的原则获取所,等待时间越长的线程越先获取到锁,这就是公平锁。而对于非公平锁而言,线程获取锁的时候,可以无视等待队列而直接获取锁。最典型的独占锁就是ReentrantLock。
  • 共享锁:同一个时候能够被多个线程获取的锁,能被共享的锁。CyclicBarrie,CountDownLatch和Semaphore都是共享锁。

同步器AQS中包含两个节点类型大的引用:一个指向头节点的引用head,一个指向尾节点的引用tail。当一个线程成功地获取到锁(同步状态),其他线程无法获取到锁,而是被构造成包含当前线程,等待状态等信息的节点加入到同步队列中等待获取到这个锁的线程释放锁。这个加入队列的过程,必须保证线程安全。因此同步器提供了CAS原子的设置尾节点的方法(保证一个未获取到同步状态的线程加入到同步队列后,下一个未获取到的线程才能够加入。)
同步队列遵循FIFO,头结点是获取锁(同步状态)成功地节点,头节点在释放同步状态的时候回幻想后继节点,而后集结点将会在获取锁成功时将自己设置为头结点。设置头节点是由获取锁成功地线程来完成的,由于只有一个线程可以成功获取同步状态,所以设置头结点的方法并不需要CAS保证,只需要将头结点设置为原首节点的后继节点,并断开原头结点的next引用。

当前线程通过tryAcquire()方法尝试获取锁,成功地话直接返回,失败的话进入等待队列排队等待,这个通过构造同步节点的addWaiter方法将节点加入到同步队列的队列尾部。最后调用acquireQueued方法,使该节点以死循环的方法是获取同步状态,如果获取不到,则阻塞节点中的线程。

同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态。

参考:
《JUC回顾之-AQS同步器的实现原理》

9. CAS无锁的概念、乐观锁和悲观锁

悲观锁:总是假设最坏的情况,每次去获取数据时都认为数据会被别人修改,所以每次获取时都会上锁。传统的关系型数据库就应用了多多悲观锁:行锁,表锁,读锁,写锁等,都是在做操作之前先上锁。synchronized的实现其实也是一种悲观锁。

乐观锁:每次去获取数据的时候都认为别人不会修改,所以不会上锁,但在更新时会利用版本号等机制判断下在此期间别人有没有去更新这个数据。乐观锁更多应用于多读的场合,可以提高吞吐量,像数据库提供的类似write——condition机制,本质都是乐观锁。而util的concurrent包下的原子变量类就使用了乐观锁中的一种实现方式CAS实现的。

CAS比较并交换Compare and Swap:是一种乐观锁计数。而无所算法(noblocking algorithms)使用底层原子化的机器指令例如CAS来代替锁并保证并发情况下数据的完整性。无锁算法广泛应用于JVM的线程和进程的调度,垃圾手机,实现所和其他并发数据结构等场合。CAS操作包含三个操作数:内存位置V,预期原值A和新值B。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置更新为新值,否则处理器不做任何操作。无论任何情况,它都会在CAS执行之前返回该位置的值。

参考:
《Java并发问题--乐观锁与悲观锁以及乐观锁的一种实现方式-CAS》 《无锁算法CAS 概述 》

10. 常见的原子操作类

原子操作类相当于泛化的volatile变量,能够支持原子读取-修改-写操作。比如AtomicInteger表示一个int类型的数值,提供了get和set方法,这些volatile类型的变量在读取和写入上有着相同的内存语义。原子操作类共有13个,在java.util.concurrent.atomic包下,分为四种类型的原子更新类:

  1. 原子更新基本类型:使用原子方式更新基本类。分为AtomicBoolean原子更新布尔变量;AtomicInteger原子更新整型变量;AtomicLong原子更新长整型变量。
  2. 原子更新数组:通过原子更新数组里的某个元素。AtomicIntegerArray:原子更新整型数组的某个元素;AtomicLongArray:原子更新长整型数组的某个元素;AtomicReferenceArray:原子更新医用类型数组的某个元素。
  3. 原子更新引用类型:AtomicReference:原子更新引用类型;AtomicReferenceFieldUpdater:原子更新引用类型里的字段;AtomicMarkableReference:原子更新带有标记位的引用类型。
  4. 原子更新字段类:AtomicIntegerFieldUpdater:原子更新整型字段;AtomicLongFieldUpdater:原子更新长整型字段;AtomicStampedReference:原子更新带有版本号的引用类型。

参考:
《Java并发编程系列之十九:原子操作类》

11. 什么是ABA问题,出现ABA问题JDK是如何解决的

ABA问题多出现于多线程或多进程计算环境中。
假如两个线程T1和T2访问同一个变量V,当T1访问变量V是,读取到的V的值为A;此时线程T1倍阻塞,T2开始执行,T2先将变量V的值从A变成了B,然后又将变量V从B变回了A;此时T1又占有了CPU继续执行,他发现变量V的值还是A,通过了CAS检测,认为没有发生变化,所以继续执行。这个过程中,即是ABA问题。在这里,如果程序只关心值,name并不会导致什么问题;但如果需要知道之前的过程中值是否发生了变化,则ABA问题就会导致判断失误了。

各种乐观锁的实现中通常都会用版本戳Version来对记录或对象标记,来避免并发操作带来的问题。
在Java中,AtomicStampedReference也实现了这个作用,它通过包装[E,Integeer]的远足来对对象标记版本戳stamp,从而避免ABA问题。

《传说中的并发编程ABA问题》
《JAVA与ABA问题》

12. 乐观锁的业务场景及实现方式(此题可能存在错误)

乐观锁多应用于并发量大,而读操作相比写操作占比更大的业务场景中。悲观锁在实际的高并发生产环境中会带来极其大的性能问题,这种情况使用乐观锁可以更好的提高性能。

乐观锁多使用版本戳Version来实现。一般通过为数据库表增加一个数字类型的version字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当提交更新的时候,判断数据库表对应的当前版本信息是否与取出时的相同,如果一致则通过,允许更新;不一致则认为时过期数据,不予更新。而乐观锁的典型实现CAS就是基于此的。

参考:
《乐观锁与悲观锁各自适用场景是什么?》
《乐观锁以及乐观锁的实现》

13. Java 8并法包下常见的并发类(此题由于原文内容太多选择了部分复制原文内容)

JDK1.8的并发工具包由三个包组成,分别是java.util.concurrent、java.util.concurrent.atomic和java.util.concurrent.locks。它们提供了大量关于并发的接口,类,原子操作类,锁等相关类。我们可以借助concurrent包实现复杂的并发操作。

13.1 阻塞队列BlockingQueue
在BlockingQueue中,生产者可以持续向队列插入新的元素,直到队列满为止。队列满后生产者线程被阻塞,消费者可以持续从队列取出元素,直到队列空为止,而队列空后消费者线程将被阻塞。
实现类:

  • ArrayBlockingQueue:基于数组实现的有界阻塞队列,创建后不能修改队列的大小
  • LinkedBlockingQueue:基于链表实现的有界阻塞队列,默认大小为Integer.MAX_VALUE,有较好的吞吐量,但可预测性差。
  • PriorityBlockingQueue:具有优先级的无界阻塞队列,不允许插入null,所有元素都必须可比较(即实现Comparable接口)。
  • SynchronousQueue:只有一个元素的同步队列。若队列中有元素插入操作将被阻塞,直到队列中的元素被其他线程取走。
  • DelayQueue:无界阻塞队列,每个元素都有一个延迟时间,在延迟时间之后才释放元素。

13.2 阻塞双端队列BlockingDueue
Dueue=Double Ended Queeu。生产者和消费者可以在队列的两端进行插入和删除操作。
实现类:
LinkedBlockingDeque:基于双向链表实现的有界阻塞队列,默认大小为Integer.MAX_VALUE,有较好的吞吐量,但可预测性差

13.3 阻塞转移队列TransferQueue
TransferQueue接口继承了BlockingQueue接口,因此具有BlockingQueue接口的所有方法,并增加了一些方法。
Java 8 提供了一个基于链表的实现类LinkedTransferQueue。

13.4 并发容器

  • ConcurrentMap接口继承了普通的Map接口,提供了线程安全和原子操作特性。Java提供了其实现类ConcurrentHashMap
  • ConcurrentNavigableMap接口继承了ConcurrentMap和NavigableMap接口,支持并发访问NavigableMap,还能让子Map具备并发访问的能力。NavigableMap是扩展的 SortedMap,具有了针对给定搜索目标返回最接近匹配项的导航方法。Java提供了实现类ConcurrentSkipListMap,并没有使用lock来保证线程的并发访问和修改,而是使用了非阻塞算法来保证并发访问,高并发时相对于TreeMap有明显的优势。

13.5 线程池

  • FixedThreadPool:固定大小的线程池,创建时指定大小;
  • WorkStealingPool:拥有多个任务队列(以便减少连接数)的线程池;
  • SingleThreadExecutor:单线程执行器,顾名思义只有一个线程执行任务;
  • CachedThreadPool:根据需要创建线程,可以重复利用已存在的线程来执行任务;
  • SingleThreadScheduledExecutor:根据时间计划延迟创建单个工作线程或者周期性创建的单线程执行器;
  • ScheduledThreadPool:能够延后执行任务,或者按照固定的周期执行任务。

13.6 锁

  • ReadWriteLock:读写锁接口,允许多个线程读取某个资源,但是一次只能有一个线程进行写操作。内部有读锁、写锁两个接口,分别保护读操作和写操作。实现类为ReentrantReadWriteLock。
  • ReentrantLock:可重入锁,具有与使用 synchronized 方法和语句所访问的隐式监视器锁定相同的一些基本行为和语义,但功能更强大。ReentrantLock 将由最近成功获得锁定,并且还没有释放该锁定的线程所拥有。当锁定没有被另一个线程所拥有时,调用 lock 的线程将成功获取该锁定并返回。如果当前线程已经拥有该锁定,此方法将立即返回。内部有一个计数器,拥有锁的线程每锁定一次,计数器加1,每释放一次计数器减1。

13.7 原子类型

  • AtomicBoolean:可原子操作的布尔对象;
  • AtomicInteger:可原子操作的整形对象;
  • AtomicLong:可原子操作的长整形对象;
  • AtomicReference:可原子操作的对象引用。

13.8 并发工具

  • CountDownLatch倒计时。用于一个或者多个线程等待一系列指定操作的完成,线程会阻塞到所有操作完成后才允许直行。
  • CyclicBarrier栅栏。所有线程都必须等待的一个栅栏,直到所有线程都到达这里,才允许这些线程运行。
  • Exchanger交换机。表示一种汇合点,两个线程可以在这里交换对象。
  • Semaphore信号量。Semaphore可以控制某个资源可被同时访问的个数。

参考:
《Java 8并发工具包漫游指南》
《Java并发包中常用类小结(一)》

14. 偏向锁、轻量级锁、重量级锁、自旋锁的概念

在JDK1.6中锁一共有四种状态:无锁,偏向锁,轻量级锁和重量级锁,它会随着竞争情况之间升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种策略目的是为了提高获得锁和释放锁的效率。

14.1 偏向锁
大多情况下锁不仅不存在多线程竞争,而且总是由统一线程多次获得。偏向锁的目的是在某个线程获得锁之后,消除这个县城所重入(CAS)的开销,看起来让这个线程得到了偏护。偏向锁使用了一种等到竞争出现时才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

14.2 自旋锁
线程的阻塞和唤醒需要CPU从用户态转为和心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作。同时我们可以发现,很多对象锁的锁定状态只会持续很短的一端时间,例如整数的自加操作,在很短的时间内阻塞并唤醒线程显然不值得,所以加入了自旋锁。
所谓自旋,就是让线程去执行一个无意义的循环,循环结束后再去重新竞争锁,而不是阻塞后唤醒一个线程。自旋锁省去了阻塞锁的时间空间开销,但长时间自旋就变成了“忙式等待”,所以自旋一般控制在一个范围内,超过了就升级为阻塞锁。

14.3 轻量级锁
轻量级锁是由偏向锁升级而来的,偏向锁运行在一个线程进入同步块的请胯下,当第二个线程加入锁征用的时候,偏向锁就会升级为轻量级锁。
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,则自旋获取锁,当自旋获取锁仍然失败时,表示存在其他线程竞争锁(两条或两条以上的线程竞争同一个锁),则轻量级锁会膨胀成重量级锁。

14.4 重量级锁
重量级锁在JVM中是由对象监视器Monitor实现的。线程的竞争不适用自旋,不会消耗CPU,但线程阻塞,响应时间缓慢。

参考:
《偏向锁,轻量级锁,自旋锁,重量级锁的详细介绍》
《个人对于轻量级锁、重量级锁的理解》

你可能感兴趣的:(锁机制)