Java并发编程知识点总结Volatile、Synchronized、Lock实现原理

允我心安 提交于 2019-11-28 20:40:53

Volatile关键字及其实现原理

  在多线程并发编程中,Volatile可以理解为轻量级的Synchronized,用volatile关键字声明的变量,叫做共享变量,其保证了变量的“可见性”以及“有序性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。可见性是由Java内存模型保证的(底层还是通过内存屏障实现的),即某个线程改变共享变量的值之后,会立即同步到主内存,线程每次使用共享变量的时候都先从内存中读取刷新它的值;而有序性是通过“内存屏障”实现的,通过禁止指令重排序,从而使得某些代码能以一定的顺序执行。

  但是Volatile关键字不能保证对共享变量操作的“原子性”,比如自增操作(i++)就不是原子操作,其可以分解为:①从内存中读取i的值,②对i进行自增操作,③将i的值写入到内存中;因此volatile关键字也不能完全保证多线程下的安全性。

  在了解volatile关键字的原理之前,首先来看一下与其实现原理相关的CPU术语与说明。

 

1.volatile实现原理相关的相关CPU术语与说明

  通过对声明了volatile变量的Java语句进行反编译可以发现,有volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,其中Lock前缀的指令在多核处理器下会引发两件事情:

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

  这里的缓存指的是CPU的高速缓存,计算机为了提高处理的速度,处理器不直接和内存进行通信,而是将系统内存中的数据读取到高速缓存(L1L2等)之后再进行操作,但操作不知道何时会写到内存。

  如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作还是会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

 

 

2.声明了volatile变量的Java语句反编译结果

 

Volatile内存语义的实现

  为了实现Volatile的内存语义,JMM会分别限制编译器重排序和处理器重排序。3-5JMM针对编译器制定的volatile重排序规则表。

  1. 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  2. 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。、
  3. 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

 

在编译器生成字节码的时候,会在指令序列中通过插入内存屏障来禁止特定类型的处理器重排序。JMM基于保守策略插入内存屏障。

  1. 在每个volatile写操作的前面插入一个StoreStore屏障;
  2. 在每个volatile写操作的后面插入一个StoreLoad屏障;
  3. 在每个volatile读操作的后面插入一个LoadLoad屏障
  4. 在每个volatile读操作的后面插入一个LoadStore屏障。

 

 

 

 

Synchronized及其实现原理

  在多线程并发编程中synchronized一直是元老级角色,很多人称其为重量级锁,Java SE 1.6 Synchronized进行了大量的优化,包括引入偏向锁和轻量级锁,减少了获取锁和释放锁带来的性能消耗,使得Synchronized不再那么重量级。

  Synchronized实现同步的基础是:Java中每个变量都可以作为锁. Synchronized在日常的使用中,主要有以下三种形式:

  1. 修饰普通方法,锁的是当前实例变量(this)
  2. 修饰静态()方法,锁的是当前类的Class对象;
  3. 修饰同步方法快,锁的是Synchronized括号中的对象。

  当线程访问同步方法或者代码块时,其必须先获得锁,在退出或者抛出异常时释放锁。JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorentermonitorexit指令实现的,而方法同步是使用另外一种方式实现的(隐式调用这两个指令)。

  monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,两两配对。任何对象都有一个monitor与之关联,当一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

  Synchronized是可重入的,所谓可重入的意思就是,当某个线程获得锁时,再次请求进入有同一个锁修饰的代码块或者方法时,操作会获得成功。其中的其实原理课以简单概括为moniter对象维护了一个线程持有者变量和一个锁计数器,每当有线程尝试获取锁的时候,如果当前锁持有者为空,那么该线程将成功获得锁,同时计数器执行+1操作;如果当前锁持有者不为空,那么会先检查锁的持有者跟当前请求锁的线程是否是同一个,如果不是同一个,那么请求锁失败,该线程会进入阻塞状态,如果当前请求获取锁的线程与锁持有者是相同的,那么获取锁的请求会成功,且计数器会执行自增操作,没退出一个同步方法或者执行代码块,计数器会-1,直到为0,此时锁处于空闲状态。

Java对象头

Synchronized所用的锁是存在Java对象头里面的,如果对象是数组类型,虚拟机会用3个字宽(Word)来存储对象头,否则用2个字宽来存储对象头。在32位虚拟机中,1字宽=4字节=32bit.

 

 

3.Java对象头的长度

Java对象头里的Mark Word里默储对象的HashCode、分代年锁标记。32位JVM的Mark Word的默认存储结构如下图所示:

 

 

4.Java对象头存储结构

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

 

 

锁的升级与对比

Java SE 1.6中锁一共有四种状态,从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

1.偏向锁

大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

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

 

  1. 轻量级锁

1)轻量级锁加锁

线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

2)轻量级锁解锁

轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。图2-2是两个线程同时争夺锁,导致锁膨胀的流程图。

 

 

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

 

 

Happens-before简介

JDK 5开始,Java使用新的JSR-133内存模型(除非特别说明,本文针对的都是JSR-133内存模型)。JSR-133使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。与程序员密切相关的happens-before规则如下。

  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  2. 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  3. volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
  4. 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C

两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。

as-if-serial语义

as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序.

 

 

Java多线程

线程:

线程是系统调度的基本单位。每个线程都有自己的程序计数器、Java虚拟机栈、本地方法栈。

 

 

 

等待/通知的相关方法

等待/通知的相关方法是任意Java对象都具备的,因为这些方法被定义在所有对象的超类java.lang.Object上,方法和描述如表4-2所示。

Objecte类拥有的方法有:hashcodegetClasswaitnotifynotifyAllequalsclonefinalizetoString方法。

 

 

 

调用wait()notify()以及notifyAll()时需要注意的细节,如下

  1. 使用wait()notify()notifyAll()时需要先对调用对象加锁。
  2. 调用wait()方法后,线程状态由RUNNING变为WAITING,释放所占用的对象锁,并将当前线程放置到对象的等待队列。
  3. notify()notifyAll()方法调用后,等待线程依旧不会从wait()返回,需要调用notify()

notifAll()的线程释放锁之后,等待线程才有机会从wait()返回。

  1. notify()方法将等待队列中的一个等待线程从等待队列中移到同步队列中,而notifyAll()方法则是将等待队列中所有的线程全部移到同步队列,被移动的线程状态由WAITING 变为BLOCKED
  2. wait()方法返回的前提是获得了调用对象的锁。

 

在图4-3中,WaitThread首先获取了对象的锁,然后调用对象的wait()方法,从而放弃了锁并进入了对象的等待队列WaitQueue中,进入等待状态。由于WaitThread释放了对象的锁,NotifyThread随后获取了对象的锁,并调用对象的notify()方法,将WaitThreadWaitQueue移到SynchronizedQueue中,此时WaitThread的状态变为阻塞状态。NotifyThread释放了锁之后,WaitThread再次获取到锁并从wait()方法返回继续执行。

 

Lock接口

Java SE 5之后,并发包Java.util.concurrent引入了Lock接口来实现锁功能,它与synchronized的同步功能相类似,但是在使用时需要显式地获取锁和释放锁(finally块中释放锁,目的是保证在获取到锁之后,最终能够被释放),其次Lock还拥有可中断地获取锁以及超时获取锁等Synchronized不具备的功能。

 

 

Lock是一个接口,它定义了锁获取和释放的基本操作,LockAPI如表5-2所示。

 

 

 

队列同步器AQS(Abstract Queued Synchronizer)

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

同步器的主要使用方式是继承,子类通过继承同步器并实现它的抽象方法来管理同步状态,在抽象方法的实现过程中免不了要对同步状态进行更改,这时就需要使用同步器提供的3个方法(getState()setState(int newState)compareAndSetState(int expect,int update))来进行操作,因为它们能够保证状态的改变是安全的。同步器自身没有实现任何同步接口,它仅仅是定义了若干同步状态获取和释放的方法来供自定义同步组件使用,同步器既可以支持独占式地获取同步状态,也可以支持共享式地获取同步状态,这样就可以方便实现不同类型的同步组件(ReentrantLockReentrantReadWriteLockCountDownLatch等)。

顾名思义,独占锁就是在同一时刻只能有一个线程获取到锁,而其他获取锁的线程只能处于同步队列中等待,只有获取锁的线程释放了锁,后继的线程才能够获取锁。

 

 

实现自定义同步组件时,将会调用同步器提供的模板方法,这些(部分)模板方法与描述如表5-4所示。

 

 

队列同步器AQS的实现分析

AQS主要包括:同步队列独占式同步状态获取与释放共享式同步状态获取与释放以及超时获取同步状态等同步器的核心数据结构与模板方法。

  1. 同步队列

同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。

 

 

独占式同步状态获取流程,也就是acquire(int arg)方法调用流程,如图5-5所示。

 

 

 

分析了独占式同步状态获取和释放过程后,适当做个总结:在获取同步状态时,同步器维护一个同步队列,获取状态失败的线程都会被加入到队列中并在队列中进行自旋;移出队列(或停止自旋)的条件是前驱节点为头节点且成功获取了同步状态。在释放同步状态时,同步器调用tryRelease(int arg)方法释放同步状态,然后唤醒头节点的后继节点。

 

独占式超时获取同步状态doAcquireNanos(int arg,long nanosTimeout)和独占式获取同步状态acquire(int args)在流程上非常相似,其主要区别在于未获取到同步状态时的处理逻辑。acquire(int args)在未获取到同步状态时,将会使当前线程一直处于等待状态,而doAcquireNanos(int arg,long nanosTimeout)会使当前线程等待nanosTimeout纳秒,如果当前线程在nanosTimeout纳秒内没有获取到同步状态,将会从等待逻辑中自动返回。

 

 

共享式同步状态获取与释放

共享式获取与独占式获取最主要的区别在于同一时刻能否有多个线程同时获取到同步状态。

LockSupport工具

当需要阻塞或唤醒一个线程的时候,都会使用LockSupport工具类来完成相应工作。LockSupport定义了一组的公共静态方法,这些方法提供了最基本的线程阻塞和唤醒功能,而LockSupport也成为构建同步组件的基础工具。

 

LockSupport定义了一组以park开头的方法用来阻塞当前线程,以及unpark(Thread thread)方法来唤醒一个被阻塞的线程

 

Condition接口

任意一个Java对象,都拥有一组监视器方法(定义在java.lang.Object上),主要包括wait()wait(long timeout)notify()以及notifyAll()方法,这些方法与synchronized同步关键字配合,可以实现等待/通知模式。Condition接口也提供了类似Object的监视器方法,与Lock配合可以实现等待/通知模式,但是这两者在使用方式以及功能特性上还是有差别的。

 

 

阻塞队列

阻塞队列(BlockingQueue)是一个支持两个附加操作的队列。这两个附加的操作支持阻塞的插入和移除方法。

  1. 支持阻塞的插入方法:意思是当队列满时,队列会阻塞插入元素的线程,直到队列不满。
  2. 支持阻塞的移除方法:意思是在队列为空时,获取元素的线程会等待队列变为非空。

阻塞队列常用于生产者和消费者的场景,生产者是向队列里添加元素的线程,消费者是从队列里取元素的线程。阻塞队列就是生产者用来存放元素、消费者用来获取元素的容器。

 

 

Java里面的7个阻塞队列

JDK 7提供了7个阻塞队列,如下。

·ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列,默认非公平,FIFO

·LinkedBlockingQueue:一个由链表结构组成的有界阻塞队列,默认和最大长度为

Integer.MAX_VALUE , FIFO

·PriorityBlockingQueue:一个支持优先级排序的无界阻塞队列。

·DelayQueue:一个使用优先级队列实现的无界阻塞队列,支持延时获取元素

·SynchronousQueue:一个不存储元素的阻塞队列,默认非公平

·LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。

·LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。

 

Java中的13个原子操作类

JavaJDK 1.5开始提供了java.util.concurrent.atomic包(以下简称Atomic包),这个包中的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。

因为变量的类型有很多种,所以在Atomic包里一共提供了13个类,属于4种类型的原子更新方式,分别是原子更新基本类型、原子更新数组、原子更新引用和原子更新属性(字段)。Atomic包里的类基本都是使用Unsafe实现的包装类。

ActomicInteger/AtomicBoolean/AtomicLong/AtomicReference

getAndIncreasement()/getAndSet()/get

 

CountDownLatch/CyclicBarrier/Semaphore

 

CountDownLatch c=new CountDownLatch(2);

CountDownLatch内部有个静态内部类继承自AQS,每个线程执行到某个位置后,执行countdownlatch.countDown()方法使得数字减一,减到0为止;所有线程都会阻塞在c.await()直到c.countDown()减到0为止,然后继续执行;

CyclicBarrier c = new CyclicBarrier(2);

CyclicBarrier中,每个线程执行到某个位置时,调用c.awit()通知主线程或者某个特定线程表示自己已经到达循环屏障了,当达到循环屏障的线程数量等于构造函数中的数字时;所有线程都再继续往下执行;

CyclicBarrier相较于CountDownLatch来说可以实现一些更高级的功能,比如可以重置计数器,比如可以知道阻塞的线程数,比如可以知道哪些线程被中断,比如可以加入别的任务,在所有线程都到达屏障时优先执行该任务;

 

Semaphore(信号量)是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源。多年以来,我都觉得从字面上很难理解Semaphore所表达的含义,只能把它比作是控制流量的红绿灯。比如××马路要限制流量,只允许同时有一百辆车在这条路上行使,其他的都必须在路口等待,所以前一百辆车会看到绿灯,可以开进这条马路,后面的车会看到红灯,不能驶

××马路,但是如果前一百辆中有5辆车已经离开了××马路,那么后面就允许有5辆车驶入马路,这个例子里说的车就是线程,驶入马路就表示线程在执行,离开马路就表示线程执行完成,看见红灯就表示线程被阻塞,不能执行。

Semaphore可以用于做流量控制,特别是公用资源有限的应用场景,比如数据库连接。假如有一个需求,要读取几万个文件的数据,因为都是IO密集型任务,我们可以启动几十个线程并发地读取,但是如果读到内存后,还需要存储到数据库中,而数据库的连接数只有10个,这时我们必须控制只有10个线程同时获取数据库连接保存数据,否则会报错无法获取数据库连接。这个时候,就可以使用Semaphore来做流量控制,如代码清单8-7所示。

 

Java中的线程池

使用线程池的好处:

  1. 降低资源消耗;通过重复利用已经创建的线程降低线程创建和销毁造成的消耗。
  2. 提高响应速度;当任务达到时,任务可以不需要等到线程创建就能立即执行。
  3. 提高线程的可管理性;线程不能无限制地创建,否则会消耗系统资源以及降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。

 

 

 

利用Executors.newFixedThreadPool()创建一个含有N个线程的线程池;其中工厂方法调用ThreadPoolExecutor的构造方法,创建了一个核心线程数和最大线程数都是N的线程池,该线程池的keepalivedTime设置为0L,意味着,非核心线程一旦闲暇就会被终止,同时其采用LinkedBlockingQueue(一种以链表为基础的游街阻塞队列,不传参数默认创建IntegerMAX_VALUE大小的阻塞队列)

1 //利用Executors.newFixedThreadPool
2 
3 public static ExecutorService newFixedThreadPool(int nThreads, ThreadFactory threadFactory) {
4     return new ThreadPoolExecutor(nThreads, nThreads,
5                                   0L, TimeUnit.MILLISECONDS,
6                                   new LinkedBlockingQueue<Runnable>(),
7                                   threadFactory);
8 }

 

利用Executors.newSingleThreadExecutor()创建一个大小为1的线程池;其最终调用的是ThreadPoolExecutor的构造方法创建了一个核心线程数和最大线程数都为1的线程池,keepaliveTime设置为0L,意味着非核心线程一旦闲下来就会被终止,其采用LinkedBlockingQueue作为阻塞队列。

1 public static ExecutorService newSingleThreadExecutor() {
2     return new FinalizableDelegatedExecutorService
3         (new ThreadPoolExecutor(1, 1,
4                                 0L, TimeUnit.MILLISECONDS,
5                                 new LinkedBlockingQueue<Runnable>()));
6 }

 

利用Executors.newCachedThreadPool()创建一个核心线程数为0,最大线程数为Integer.MAX_VALUE,keepAlivedTime60s,并且使用SynchronousQueue阻塞队列来做任务队列().CachedThreadPool使用没有容量的SynchronousQueue作为线程池的工作队列,但CachedThreadPoolmaximumPool是无界的。这意味着,如果主线程提交任务的速度高于maximumPool中线程处理任务的速度时,CachedThreadPool会不断创建新线程。极端情况下,CachedThreadPool会因为创建过多线程而耗尽CPU和内存资源。

1 public static ExecutorService newCachedThreadPool() {
2     return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
3                                   60L, TimeUnit.SECONDS,
4                                   new SynchronousQueue<Runnable>());
5 }

 

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!