Java多线程(一):JMM内存模型、volatile、synchronized、Lock锁、重入锁ReentrantLock 原理

 

相关文章:

Java多线程(一):JMM内存模型、volatile、synchronized、Lock锁、重入锁ReentrantLock 原理

Java多线程(二):创建线程的四种方式

Java多线程(三):Executor框架、线程池、ThreadLocal、乐观锁、悲观锁、无锁CAS 原理

 

前言:

1、多线程有什么用?

(1)发挥多核CPU的优势:

单核CPU上的”多线程”那是假的多线程,同一时间CPU处理器只会处理一段逻辑,只不过线程之间切换得比较快,看着像多个线程”同时”运行罢了。多核CPU上的多线程才是真正的多线程,它能让你的多段逻辑同时工作,多线程,可以真正发挥出多核CPU的优势来,达到充分利用CPU的目的。

(2)防止阻塞:

从程序运行效率看,如果CPU使用策略不当,单核CPU不但不会发挥出多线程的优势,反而会因为在单核CPU上运行多线程导致线程上下文的切换,而降低程序整体的效率。但是单核CPU我们还是要应用多线程,就是为了防止阻塞。如果单核CPU使用单线程,那么只要这个线程阻塞了,比方说远程读取某个数据吧,对端迟迟未返回又没有设置超时时间,那么你的整个程序在数据返回回来之前就停止运行了。多线程可以防止这个问题,多条线程同时运行,哪怕一条线程的代码执行读取数据阻塞,也不会影响其它任务的执行。

PS:多线程的上下文切换是指CPU控制权由一个已经正在运行的线程切换到另外一个就绪并等待获取CPU执行权的线程的过程。

(3)便于建模:

假设有一个大的任务A,单线程编程,那么就要考虑很多,建立整个程序模型比较麻烦。但是如果把这个大的任务A分解成几个小任务,任务B、任务C、任务D,分别建立程序模型,并通过多线程分别运行这几个任务,那就简单很多了。

2、什么是线程安全:

如果代码在多线程下执行和在单线程下执行永远都能获得一样的结果,那么你的代码就是线程安全的。

造成线程安全问题的主要原因有两点,一个存在共享数据(临界资源),而是存在多条线程共同操作共享数据。

 

 

一、JMM内存模型:

1、什么是Java内存模型:

Java内存模型是Java虚拟机定义的一种多线程访问Java内存的规范,定义了程序中各个变量的访问规则:

Java内存模型将内存分为了主内存和工作内存。主内存是存放所有的共享变量的区域,所有线程都可以访问。每条线程都有自己的工作内存,存储了该线程使用到的变量的副本拷贝,线程对变量的操作必须在自己的工作内存中完成,不能直接操作主存中的变量,首先将变量从主内存拷贝到自己的工作内存中,然后对变量进行操作,操作完成后再将变量写回主存。因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。

2、JMM是围绕原子性,有序性、可见性展开的:

(1)原子性:原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。

(2)可见性:可见性指的是,当一个线程修改了某个共享变量的值,其他线程能够马上得知这个修改的值。

(3)有序性:在有序性部分,有个很重要的概念,叫做指令重排序。

指令重排序:计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,但是不管怎么重排序,程序的执行结果不能改变。但是,指令重排序只能保证单线程中串行语义的执行的一致性,但是不能保证多线程间语义一致性。

3、相关问题解决措施:

(1)原子性问题:除了JVM自身提供对基本数据类型读写操作的原子性外,对于方法级别或者代码级别的原子性操作,可以使用synchronized关键字或者重入锁(ReentrantLock)保证程序执行的原子性。

(2)可见性问题:工作内存与主内存同步延迟现象导致的可见性问题,可以使用synchronized关键字或者volatile关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见。

(3)有序性问题:则可以利用volatile关键字解决。

4、理解JMM中的happens-before 原则:

倘若在程序开发中,仅靠synchronized和volatile关键字来保证原子性、可见性以及有序性,那么编写并发程序可能会显得十分麻烦,在Java内存模型中,还提供了happens-before 原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据,happens-before 原则内容如下:

  1. 程序顺序原则:即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
  2. 锁规则:解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
  3. volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
  4. 线程启动规则:线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见。
  5. 传递性:A先于B ,B先于C 那么A必然先于C
  6. 线程终止规则:线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。
  7. 线程中断规则:对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
  8. 对象终结规则:对象的构造函数执行,结束先于finalize()方法。

二、volatile关键字:

1、volatile关键字的作用与实现原理:

volatile是Java虚拟机提供的轻量级的同步机制,是线程不安全的,volatile跟可见性和有序性都有关,被volatile修饰的共享变量,具有:

(1)保证不同线程对该变量操作的内存可见性:

当一个变量被volatile修饰时,那么对它的修改会立刻刷新到主存,当读取一个volatile变量时,JMM会把该线程对应的工作内存置为无效,那么该线程只能从主存中重新读取共享变量,保证读取到最新值,而普通变量则不能保证这一点。

其实通过synchronized和Lock也能够保证可见性,线程在释放锁之前,会把共享变量值都刷回主存,但是synchronized和Lock的开销都更大。

(2)禁止指令重排序(内存屏障):

如果把加入volatile关键字的代码和未加入volatile关键字的代码都生成汇编代码,会发现加入volatile关键字的代码会多出一个内存屏障指令(是一个CPU指令)。内存屏障提供了以下功能:

①重排序时不能把后面的指令重排序到内存屏障之前的位置;

②使得本CPU的缓存写入内存,写入动作也会引起别的CPU或者别的内核无效化其Cache,相当于让新写入的值对别的线程可见。(利用该特性实现volatile的内存可见性)

总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。

2、volatile的两点内存语义能保证可见性和有序性,但是不能保证原子性:

volatile不能保证原子性,对单个volatile变量的读/写具有原子性,但是对于类似volatile++这样的复合操作就无能为力了。要想保证原子性,只能借助于synchronized,Lock以及并发包下的atomic的原子操作类了。

3、volatile使用场景:

(1)状态量标记:这种对变量的读写操作,标记为volatile可以保证修改对线程立刻可见。比synchronized,Lock有一定的效率提升。

while (!close){ System.out.println(“safe….”); }

对于boolean变量close值的修改属于原子性操作,因此可以通过使用volatile修饰变量close,使用该变量对其他线程立即可见,从而达到线程安全的目的。

(2)单例模式的实现,典型的双重检查锁定(DCL):

这是一种懒汉的单例模式,使用时才创建对象,而且为了避免初始化操作的指令重排序,给instance加上了volatile。指令重排序会导致,当一条线程访问的instance不为null时,但是实际上instance实例未必已初始化完成,也就造成了线程安全问题。(2)单例模式的实现,典型的双重检查锁定(DCL)

 

 

三、synchronized关键字:

1、synchronized的作用:

synchronized可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块,其原理是通过当前线程持有当前对象锁,从而拥有访问权限,而其他没有持有当前对象锁的线程无法拥有访问权限,也就保证了线程安全。synchronized也可以保证线程的可见性。synchronized属于隐式锁,锁的持有与释放都是隐式的,我们无需干预。

synchronized最主要的三种应用方式:

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

修饰静态方法:作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁

修饰代码块,指定加锁对象。对给定对象加锁,进入同步代码库前要获得给定对象的锁。

2、Synchronized底层语义原理:

Java虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现,分为显式同步(同步代码块,有明确的monitorenter和monitorexit指令)还是隐式同步(同步方法)。同步方法并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的。

每个对象的对象头都关联着一个monitor对象,当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的。每个等待锁的线程都会被封装成ObjectWaiter对象,存放在ObjectMonitor中,ObjectMonitor中有两个队列,_WaitSet和_EntryList,,_owner区域指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor后进入 _Owner 区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1;若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示:

《Java多线程(一):JMM内存模型、volatile、synchronized、Lock锁、重入锁ReentrantLock 原理》

3、synchronized代码块底层原理:

synchronized同步语句块的实现使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁)所对应的 monitor 的持有权:

(1)当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。

(2)如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor,重入时计数器的值也会加1。

(3)若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor。

编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。

4、synchronized方法底层原理:

synchronized同步方法的实现是隐式的,无需通过字节码指定来控制,他实现在方法调用和返回操作之中。JVM可以通过 方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志 判断一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor, 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。

如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。

ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。

5、synchronized的优化:

在早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

在Java6之后,synchronized在JVM层面做了优化,减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁。

锁级别:偏向锁->轻量级锁->自旋锁->重量级锁:

(1)偏向锁:如果一个线程获得了锁,那么进入偏向模式,当这个线程再次请求锁的时候,无需再做任何同步操作,这样就省去了大量有关锁申请的操作。适用于连续多次都是同一个线程申请相同的锁的场景。

(2)轻量级锁:适用于多个线程交替执行同步块的时候

(3)自旋锁:自旋锁是一种假设在不久将来,当前的线程可以获得锁。因此在轻量级锁升级成为重量级锁之前虚拟机会让当前想要获取锁的线程做几个空循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起。

这种方式确实也是可以提升效率的。但问题是当线程越来越多竞争很激烈时,占用CPU的时间变长会导致性能急剧下降,因此Java虚拟机内部一般对于自旋锁有一定的次数限制,可能是50或者100次循环后就放弃,直接挂起线程,让出CPU资源。

(4)重量级锁:适用于多个线程同时执行同步代码块的场景。

 

 

四、Lock接口:

1、什么是lock接口

lock是显式锁,锁的持有与释放都必须由我们手动编写,JAVA5之后在concurrent并发包中加入了Lock接口,当前线程使用lock()方法与unlock()对临界区进行包围,其他线程由于无法持有锁将无法进入临界区,直到当前线程释放锁,unlock()操作必须在finally代码块中,这样可以确保即使临界区执行抛出异常,线程最终也能正常释放锁。

Lock锁在使用上具有更大的灵活性,提供了synchronized所不具备的其他同步特性,如可中断锁的获取(synchronized在等待获取锁时是不可中的),超时中断锁的获取,等待唤醒机制的多条件变量Condition等。

2、lock和synchronized的区别

(1)synchronized是Java语言的关键字,是隐式锁,当synchronized方法或者synchronized代码块执行完之后,系统会自动让线程释放对锁的占用;Lock是一个接口,是显式锁,必须要用户去手动调用unlock()释放锁,如果没有主动释放锁,就有可能导致出现死锁现象。

(2)synchronized在发生异常时,JVM会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象。

(3)Lock锁在使用上比synchronized具有更大的灵活性,synchronized能够完成的功能,Lock锁基本也能完成,同时lock锁还拥有synchronized锁所没有的其他功能。比如:

①Lock可以让等待锁的线程响应中断,而synchronized却不行,使用synchronized时,等待的线程会一直等待下去,不能够响应中断;

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

③Lock可以提高多个线程进行读操作的效率(读写锁)。

④Lock接口的实现类ReentrantLock可以添加多个检控条件,如果使用synchronized,则只能有一个,而使用ReentrantLock可以有多个等待队列。

(4)Lock可以实现公平锁,Synchronized不保证公平性。

(5)在JDK1.6以前,在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而当竞争资源非常激烈时(即有大量线程同时竞争),此时Lock的性能要远远优于synchronized。但是在JDK1.6及以后的版本,JVM对synchronized进行了优化,所以两者的性能变得差不多了。

 

 

五、重入锁ReentrantLock:

1、什么是重入锁ReentrantLock

ReentrantLock实现了Lock接口,作用与synchronized关键字相当,但比synchronized更加灵活。ReentrantLock本身是一种支持重进入的锁,即该锁可以支持一个线程对资源的重复加锁,同时也支持公平锁和非公平锁。ReentrantLock是基于AQS并发框架实现的:

在JDK 1.6之后,虚拟机对synchronized关键字进行整体优化后,在性能上synchronized与ReentrantLock已没有明显差距,因此在使用选择上,需要根据场景而定,大部分情况下我们依然建议是synchronized关键字,原因之一是使用方便语义清晰,二是性能上虚拟机已为我们自动优化。而ReentrantLock提供了多样化的同步特性,如超时获取锁、可以被中断获取锁(synchronized的同步是不能中断的)、等待唤醒机制的多个条件变量(Condition)等,因此当我们确实需要使用到这些功能时,可以选择ReentrantLock。

2、读写锁:ReadWriteLock:

ReentrantLock某些时候有局限。如果使用ReentrantLock,可能本身是为了防止线程A在写数据、线程B在读数据造成的数据不一致,但这样,如果线程C在读数据、线程D也在读数据,读数据是不会改变数据的,没有必要加锁,但是还是加锁了,降低了程序的性能。因此诞生了读写锁ReadWriteLock。ReadWriteLock是一个读写锁接口,ReentrantReadWriteLock是ReadWriteLock接口的一个具体实现,实现了读写的分离,读锁是共享的,写锁是独占的,读和读之间不会互斥,读和写、写和读、写和写之间才会互斥,提升了读写的性能。

3、AbstractQueuedSynchronizer(AQS框架):

AQS框架是J.U.C中实现锁及同步机制的基础,是一个抽象类,主要是维护了一个int类型的state属性和一个非阻塞、先进先出的线程等待队列;其中state是用volatile修饰的,保证线程之间的可见性和控制同步状态,当state=0时,则说明没有任何线程占有共享资源的锁,当state=1时,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列进行等待;

AQS内部通过内部类Node构成FIFO的同步队列来完成线程获取锁的排队工作,同时利用内部类ConditionObject构建等待队列,当Condition调用wait()方法后,线程将会加入等待队列中,而当Condition调用signal()方法后,线程将从等待队列转移动同步队列中进行锁竞争。AQS中只能存在一个同步队列,但可拥有多个等待队列。

队列的入队和出对操作都是无锁操作,基于自旋锁和CAS实现;AQS分为两种模式:独占模式和共享模式,像ReentrantLock是基于独占模式模式实现的,Semaphore,CountDownLatch、CyclicBarrier等是基于共享模式。

4、Synchronizers(同步器):

J.U.C的同步器主要用于协助线程同步,有以下四种:

(1)闭锁 CountDownLatch:闭锁主要用于让一个主线程等待一组事件发生后继续执行。

(2)栅栏 CyclicBarrier:栅栏主要用于等待其它线程,且会阻塞自己当前线程,所有线程必须同时到达栅栏位置后,才能继续执行;且在所有线程到达栅栏处,可以触发执行另外一个预先设置的线程。

(3)信号量 Semaphore:信号量主要用于控制访问资源的线程个数,常常用于实现资源池,如数据库连接池,线程池。在Semaphore中,acquire方法用于获取资源,有的话,继续执行,没有资源的话将阻塞直到有其它线程调用release方法释放资源;

(4)交换器 Exchanger:交换器主要用于线程之间进行数据交换;当两个线程都到达共同的同步点(都执行到exchanger.exchange的时刻)时,发生数据交换,否则会等待直到其它线程到达;

问题:CyclicBarrier和CountDownLatch的区别

回答:两者都在java.util.concurrent下,都可以用来表示代码运行到某个点上,二者的区别在于:

①CyclicBarrier的某个线程运行到某个点上之后,该线程即停止运行,直到所有的线程都到达了这个点,所有线程才重新运行;CountDownLatch则不是,某线程运行到某个点上之后,只是给某个数值-1而已,该线程继续运行;

②CyclicBarrier只能唤起一个任务,CountDownLatch可以唤起多个任务;

③CyclicBarrier可重用,CountDownLatch不可重用,计数值为0该CountDownLatch就不可再用了。

5、Condition接口:

(1)什么是Condition接口?Condition的使用?

Condition对象是由Lock对象创建的,一个Lock对象可以创建多个Condition,其实Lock和Condition都是基于AQS实现的。Condition对象主要用于线程的等待和唤醒,在JDK5之前,线程的等待唤醒是用Object对象的wait/notify/notifyAll方法实现的,这些方法必须配合着synchronized关键字使用,使用起来不是很方便;

在JDK5之后,J.U.C包提供了Condition,其中:

Condition.await对应于Object.wait;

Condition.signal 对应于 Object.notify;

Condition.signalAll 对应于 Object.notifyAll;

与synchronized的等待唤醒机制相比,Condition具有更多的灵活性以及精确性,这是因为notify()在唤醒线程时是随机(同一个锁),而Condition则可通过多个Condition实例对象建立更加精细的线程控制,也就带来了更多灵活性了,我们可以简单理解为以下两点:

①通过Condition能够精细的控制多线程的休眠与唤醒。

②对于一个锁,我们可以为多个线程间建立不同的Condition。

毕竟同一个锁,对于synchronized关键字来说只能有一组等待唤醒队列,而不能像Condition一样,同一个锁拥有多个等待队列。

(2)Condition的实现原理:

Condition的具体实现类是AQS的内部类ConditionObject,AQS中存在两种队列,一种是同步队列,一种是等待队列,而等待队列就相对于Condition而言的。

每个Condition都对应着一个等待队列,也就是说如果一个锁上创建了多个Condition对象,那么也就存在多个等待队列。等待队列是一个FIFO的队列,在队列中每一个节点都包含了一个线程的引用,而该线程就是Condition对象上等待的线程。当一个线程调用了await()相关的方法,那么该线程将会释放锁,并构建一个Node节点封装当前线程的相关信息加入到等待队列中进行等待,直到被唤醒、中断、超时才从队列中移出。

 

 

    原文作者:java内存模型
    原文地址: https://blog.csdn.net/a745233700/article/details/86547767
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞