Java并发机制及锁的实现原理

Java并发编程概述

并发编程的目的是为了让程序运行得更快,但是,并不是启动更多的线程就能让程序最大限度地并发执行。在进行并发编程时,如果希望通过多线程执行任务让程序运行得更快,会面临非常多的挑战,比如上下文切换的问题、死锁的问题,以及受限于硬件和软件的资源限制问题,本章会介绍几种并发编程的挑战以及解决方案。

上下文切换

即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停地切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。
CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。
这就像我们同时读两本书,当我们在读一本英文的技术书时,发现某个单词不认识,于是便打开中英文字典,但是在放下英文技术书之前,大脑必须先记住这本书读到了多少页的第多少行,等查完单词之后,能够继续读这本书。这样的切换是会影响读书效率的,同样上下文切换也会影响多线程的执行速度。

多线程一定快吗

下面的代码演示串行和并发执行并累加操作的时间,请分析:下面的代码并发执行一定比串行执行快吗?
  1. public class ConcurrentTest {  
  2.     private static final long count=10001;  
  3.     public static void main(String[] args) throws InterruptedException{  
  4.         concurrency();  
  5.         serial();  
  6.     }  
  7.     private static void concurrency() throws InterruptedException{  
  8.         long start=System.currentTimeMillis();  
  9.         Thread thread=new Thread(new Runnable(){  
  10.             @Override  
  11.             public void run(){  
  12.                 int a=0;  
  13.                 for(long i=0;i<count;i++){  
  14.                     a++;  
  15.                 }  
  16.             }  
  17.         });  
  18.         thread.start();  
  19.         int b=0;  
  20.         for(long i=0;i<count;i++){  
  21.             b--;  
  22.         }  
  23.         thread.join();  
  24.         long time=System.currentTimeMillis()-start;  
  25.         System.out.println("concurrency:"+time);   
  26.     }  
  27.   
  28.     private static void serial(){  
  29.         long start=System.currentTimeMillis();  
  30.         int a=0;  
  31.         for(long i=0;i<count;i++){  
  32.             a++;  
  33.         }  
  34.         int b=0;  
  35.         for(long i=0;i<count;i++){  
  36.             b--;;  
  37.         }  
  38.         long time=System.currentTimeMillis()-start;  
  39.         System.out.println("serial:"+time);   
  40.     }  
  41. }  
测试结果(具体数据与运行环境相关):

循环次数

串行执行耗时/ms

并发执行/ms

1

1

2

一百万

7

4

一亿

172

90

当数据不超过一百万时,并发执行速度会比串行执行快慢。那么,为什么并发执行的速度会比串行慢呢?这是因为线程有创建和上下文切换的开销。

如何减少上下文切换

减少上下文切换的方法有无锁并发编程CAS算法、使用最少线程和使用协程

无锁并发编程:多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。

CAS算法:Java的Atomic包使用CAS算法来更新数据,而不需要加锁。

使用最少线程:避免创建不需要的线程,如果任务很少 ,但是创建了很多的线程来处理,这样会造成大量线程都处于等待状态。

协程:在单线程里实现多任务的调度,并在单线程里维护多个任务间的切换。

死锁

锁是一个非常有用的工具,运用场景非常多,因为它使用起来非常简单,而且易于理解。但同时它也会带来一些困扰,那就是可能会引起死锁,一旦产生死锁,就会造成系统功能不可用。我们先看一段代码,这段代码会引起死锁,使线程threadA和线程threadB相互等待对方释放锁。

  1. public class DeadLockDemo {  
  2.     private static String A="A";  
  3.     private static String B="B";  
  4.     public static void main(String[] args){  
  5.         new DeadLockDemo().deadLock();  
  6.     }   
  7.     private void deadLock(){  
  8.         Thread threadA=new Thread(new Runnable(){  
  9.             @Override  
  10.             public void run(){  
  11.                 synchronized(A){  
  12.                     try {  
  13.                         Thread.currentThread().sleep(2000);  
  14.                     } catch (InterruptedException e) {   
  15.                         e.printStackTrace();  
  16.                     }  
  17.                     synchronized(B){  
  18.                         System.out.println("AB");  
  19.                     }  
  20.                 }  
  21.             }  
  22.         });  
  23.         Thread threadB=new Thread(new Runnable(){  
  24.             @Override  
  25.             public void run(){  
  26.                 synchronized(B){  
  27.                     try {  
  28.                         Thread.currentThread().sleep(2000);  
  29.                     } catch (InterruptedException e) {   
  30.                         e.printStackTrace();  
  31.                     }  
  32.                     synchronized(A){  
  33.                         System.out.println("BA");  
  34.                     }  
  35.                 }  
  36.             }  
  37.         });  
  38.         threadA.start();  
  39.         threadB.start();  
  40.     }  
  41. }  
一旦出现死锁,业务是可以感知的,因为不能继续提供服务了。那么,这个时候我们需要通过dump线程查看到底是哪个线程出现了问题。

1、运行上述程序

2、在命令行下执行命令:jps -l


查看运行在虚拟机上的进程,找到进程的本地虚拟机唯一ID(5024)。

3、在命令行下执行命令:jstack -l 5204


生成虚拟机当前时刻的线程快照。

不难发现,两个线程都已经锁定了(Locked)一个String对象,同时又都在等待加锁(waiting to lock)另外一个线程已经锁定的一个String对象。因此产生了死锁(deadlock)。

避免死锁的常见方法

1、避免一个线程同时获取多个锁

2、避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。

3、尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制。

资源限制的挑战

(1)什么是资源限制
资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源。
例如,服务器的带宽只有2Mb/s,某个资源的下载速度是1Mb/s每秒,系统启动10个线程下载资源,下载速度不会变成10Mb/s,所以在进行并发编程时,要考虑这些资源的限制。硬件资源限制有带宽的上传/下载速度、硬盘读写速度和CPU的处理速度。软件资源限制有数据库的连接数和socket连接数等。
(2)资源限制引发的问题
在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行,但是如果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这时候程序不仅不会加快执行,反而会更慢,因为增加了上下文切换和资源调度的时间。
(3)如何解决资源限制的问题
对于硬件资源限制,可以考虑使用集群并行执行程序。既然单机的资源有限制,那么就让程序在多机上运行。比如使用ODPS、Hadoop或者自己搭建服务器集群,不同的机器处理不同的数据。可以通过“数据ID%机器数”,计算得到一个机器编号,然后由对应编号的机器处理这笔数据。对于软件资源限制,可以考虑使用资源池将资源复用。比如使用连接池将数据库和Socket连接复用,或者在调用对方webservice接口获取数据时,只建立一个连接。
(4)在资源限制情况下进行并发编程
如何在资源限制的情况下,让程序执行得更快呢?方法就是,根据不同的资源限制调整程序的并发度,比如下载文件程序依赖于两个资源——带宽和硬盘读写速度。有数据库操作时,涉及数据库连接数,如果SQL语句执行非常快,而线程的数量比数据库连接数大很多,则某些线程会被阻塞,等待数据库连接。

线程安全

并发处理的广泛应用是使得Amdahl定律代替摩尔定律成为计算机性能发展源动力的根本原因,也是人类压榨计算机运算能力的最有力武器。但是我们必须保证并发的安全性,在此基础上实现的高效并发才有意义。一般而言,并发的安全性也就是我们常说的线程安全。

Java语言中的线程安全

按照线程安全的安全程度由强到弱,将Java语言中的共享数据的分为如下5类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

1、不可变

在Java语言中(JDK 1.5之后),不可变(Immutable)的对象一定是线程安全的。无论是对象的方法实现还是方法的调用者,都不需要额外采取任何的线程安全保障措施。只要一个不可变对象被正确的构建出来,那么其外部的可见状态永远也不会改变,永远也不会看到它在多线程之中处于不一致的状态。“不可变”带来的安全性是最简答和最纯粹的。

在Java语言中,如果共享数据是一个基本数据类型,那么只需要在定义时使用final关键字修饰它就可以保证它是不可变的。

如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响才行。例如,java.lang.String类的对象,它就是一个典型的不可变对象,我们调用它的substring() 、replace()和concat()这些方法都不会影响它原来的值,只会返回一个新构建的字符串对象。

保证对象行为不影响自己状态的途径有很多种,其中最简单的就是把对象中带有状态的变量都声明为final,这样在构造函数结束之后,它就是不可变的。例如,java.lang.Integer构造函数,它通过将内部状态变量value定义为final来保障状态不变。

  1. private final int value;  
  2.   
  3. public Integer(int value) {  
  4.     this.value = value;  
  5. }  

2、绝对线程安全

当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或在调用方进行其他的协调操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是绝对线程安全的。一个类要达到绝对的线程安全,往往需要付出很大的代价,甚至有时候是不切实际的代价。在Java API中标注自己是线程安全的类,大多数都不是绝对线程安全的。

比如说java.util.Vector是一个线程安全的容器,相信大家都不会有异议。因为它的add(),get()和size()这类方法都被synchronized修饰,尽管这样效率低下,但确实是线程安全的。但是,即使它所有的方法都被修饰成同步的,也不意味着调用它的时候永远都不再需要同步手段了。

  1. public class VectorTest {  
  2.     private static Vector<Integer> vector=new Vector<Integer>();  
  3.     public static void main(String[] args){  
  4.         while(true){  
  5.             for(int i=0;i<10;i++){  
  6.                 vector.add(i);  
  7.             }  
  8.             Thread removeThread=new Thread(new Runnable(){  
  9.                 @Override  
  10.                 public void run(){  
  11.                     for(int i=0;i<vector.size();i++){  
  12.                         vector.remove(i);  
  13.                     }  
  14.                 }  
  15.             });  
  16.             Thread printThread=new Thread(new Runnable(){  
  17.                 @Override  
  18.                 public void run(){  
  19.                     for(int i=0;i<vector.size();i++){  
  20.                         System.out.println(vector.get(i));  
  21.                     }  
  22.                 }  
  23.             });  
  24.             removeThread.start();  
  25.             printThread.start();  
  26.             //不要同时产生过多的线程,否则会导致操作系统假死  
  27.             while(Thread.activeCount()>20);  
  28.         }  
  29.   
  30.     }  
  31. }  

尽管这里使用的Vector的get()、remove()和size()方法都是同步的,但是在多线程环境下,如果不在方法调用端做额外的同步操作的话,使用这段代码仍然不是线程安全的,因为如果另一个线程恰好在错误的时间删除了一个元素,导致打印线程中的序列i已经不再可用的话,再用序列i访问数组就会抛出一个ArrayIndeOutOfBoundsException。


如果要保证这段代码的线程安全,我们可以将代码改为:

  1. Thread removeThread=new Thread(new Runnable(){  
  2.     @Override  
  3.     public void run(){  
  4.         synchronized(vector){  
  5.             for(int i=0;i<vector.size();i++){  
  6.                 vector.remove(i);  
  7.             }  
  8.         }   
  9.     }  
  10. });  
  11. Thread printThread=new Thread(new Runnable(){  
  12.     @Override  
  13.     public void run(){  
  14.         synchronized(vector){  
  15.             for(int i=0;i<vector.size();i++){  
  16.                 System.out.println(vector.get(i));  
  17.             }  
  18.         }   
  19.     }  
  20. });  

3、相对线程安全

相对线程安全就是我们通常意义上所讲的线程安全,它需要保证对这个对象单独的操作是线程安全的,我们在调用的时候不需要做额外的保护措施。但是,对于一些特定顺序的连续调用,就可能需要在调用端使用额外的手段来保证调用的正确性。在Java语言中,大部分的线程安全类都是属于这种类型。例如Vector、HashTable和利用Collections的synchronizedCollection()方法包装的集合。

4、线程兼容

线程兼容是指对象本身并不是线程安全的,但是可以通过在调用端正确的使用同步手段来保证对象在并发环境下可以安全的使用。我们平常说的一个类不是线程安全的,绝大多数时候指的是这一种情况。Java API中大部分类都是属于线程兼容的,例如ArrayList和HashMap。

5、线程对立

线程对立是指,无论调用端是否采用同步手段,都无法在多线程环境中并发使用。这样的情况通常是有害的,应当尽量避免。

线程安全的实现方法

1、互斥同步

互斥同步是最常见的一种并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只能被一个线程使用。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式。互斥是方法,同步时目的。

2、非阻塞同步

互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也成为阻塞同步。从处理问题的方式上说,互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施(例如加锁),那就会出现问题。

随着硬件指令集的发展(比如,现代处理器对CAS(Compare and Swap)指令的支持),我们有了另一个选择:基于冲突检测乐观并发策略,通俗的讲,就是先进行操作,如果没有其他线程争用共享数据,那操作就成功了。如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断的尝试,直到成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步

3、无同步方案

要保证线程安全,并不是一定就要进行同步,两者没有因果关系。同步只是保证共享数据争用时的正确性的手段,如果一个方法不涉及共享数据,那它自然就无需任何同步措施保证正确性。

可重入代码:可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公共的系统资源、用到的状态量都由参数传入、不调用不可重入方法等。如果一个方法,它的返回结果是可预测的,只要输入了相同的数据,就都能返回相同的结果,就是可重入的。

线程本地存储:如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行?如果能保证,我们就可以把共享数据的可见范围限定在同一个线程之内,这样就无须同步也能保证线程之间不出现数据争用的问题。例如,Web交互模式中的“一个请求对应一个服务器线程”的处理方式,这种处理方式使得很多Web服务端应用都可以使用线程本地存储来解决线程安全问题。例如,ThreadLocal在spring事务管理中的应用。

Java锁的实现

Java SE 1.6中,锁一共有4中状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。

轻量级锁

轻量级锁是JDK 1.6之中加入的新型锁机制,它名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统锁的机制就称为重量级锁。首先需要强调一点的是,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。要理解轻量级锁,必须先介绍虚拟机的对象内存布局中的对象头。

Java对象头

如果对象是数组类型,则虚拟机用3个字宽存储对象头,如果是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节,即32bit。在64位虚拟机中,一字宽等于8字节,即64bit。


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


在运行期间,Mark Word里存储的数据会随着锁标志位和偏向锁标志位的变化而变化。Mark Word可能变化为存储以下4种数据:


在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下:


轻量级锁的加锁过程

在代码进入同步快的时候,如果此同步对象没有被锁定,(锁标志位为“01”状态),虚拟机首先将在当前线程的栈桢中建立一个锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。
然后,虚拟将使用CAS操作尝试将对象的MarkWord更新为指向LockWord的指针,如果这个更新操作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位将转换为“00”,即表示此对象处于轻量级锁定状态。


如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈桢(栈桢中的Lock Word),如果是说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块执行了。否则说明这个锁对象已经被其他线程占用了。如果有两条或两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也就要进入阻塞状态。

轻量级锁的解锁过程

如果对象Mark Word仍然指向当前线程的锁记录(Lock Record),就用CAS操作把对象的Mark Word用当前线程的LockRecord(加锁之前Mark Word的拷贝)进行替换,如果替换成功,整个同步过程就完成了。如果替换失败,说明有其他线程尝试获取该锁,锁就会膨胀为重量级锁。


一旦锁升级为重量级锁,就不再恢复到轻量级锁状态。在重量级锁状态下,其他线程试图获取锁时,都会被阻塞,当持有锁的线程释放锁后会唤醒这些线程,被唤醒的线程就会进行新一轮的锁竞争。

轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,但如果存在锁竞争,除了互斥量的开销,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。

偏向锁

偏向锁也是JDK 1.6中引入的一项锁优化,它的目的是消除数据在无竞争情况下的同步操作,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步操作都消除掉,连CAS操作都不做了。
偏向锁的“偏”,就是偏心的偏,它的意思就是这个锁会偏向于第一个获取它的线程。

偏向锁的加锁过程

假如当前虚拟机启用了偏向锁,那么,当锁对象第一次被线程获取的时候,虚拟机会将对象头中的锁标志位设为“01”,即偏向锁模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的MarkWord之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,只需要简单测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果测试成功虚拟机可以不再进行任何同步操作。如果测试失败,说明有另外一个线程尝试获取这个锁,偏向锁模式宣告结束,执行偏向锁的撤销。

偏向锁的撤销

撤销过程:再次检测Mark Word的偏向锁标识位(不是锁标识位)是否设置为1,即当前对象是否支持偏向锁。如果没有,则升级为轻量级锁,如果有,则继续尝试使用CAS将对象头的偏向锁指向当前线程。
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

关闭偏向锁

偏向锁在Java 6和java 7里是默认启用的,如果通过虚拟机参数关闭偏向锁,那么程序默认进入轻量级锁状态。


锁优化

自旋锁与自适应自旋锁

互斥同步对性能最大的影响是阻塞的实现,挂起线程恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力。同时虚拟机团队也注意到在许多应用中,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。如果物理机器上有两个以上的处理器,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否能够很快释放锁。为了让线程等待,我们只需要让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。

自旋锁在JDK 1.6中默认开启。自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,因此如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长,那么自旋的线程会白白浪费处理器资源,反而带来性能上的浪费。因此,自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获取锁,就应该使用传统的方式来挂起线程了。

在JDK 1.6中引入了自适应的自旋锁,自适应意味着自选的时间不再固定了,而是由上一次在同一个锁上自旋时间以及锁的拥有者的状态来决定。

锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。

例如,如下代码(看起来没有同步的代码)

  1. public String concatString(String s1,String s2,String s3){  
  2.     return s1+s2+s3;  
  3. }  
我们知道,由于String是一个不可变的类,对字符串的连接操作总是通过生成新的String对象来进行的,因此Javac编译器会对String连接做自动优化。在JDK 1.5之后,会转化成StringBuffer对象的连续append()操作。

  1. public String concatString(String s1,String s2,String s3){   
  2.     StringBuffer sb=new StringBuffer();  
  3.     sb.append(s1);  
  4.     sb.append(s2);  
  5.     sb.append(s3);  
  6.     return sb.toString();  
  7. }  
现在大家还认为这段代码没有同步吗?每个StringBuffer.append()方法都有一个同步块,锁就是sb对象。虚拟机观察变量sb,很快就会发现它的动态作用域限制在concatString()方法内部,其他线程无法访问它,因此虽然这里有锁,但是可以被完全的消除掉,在即时编译后,这段代码就会忽略掉所有的同步而直接执行了。

锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。

大部分情况下,上面的原则是对的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中,那及时没有锁竞争,频繁的进行互斥同步操作也会导致不必要的性能损耗。

连续的StringBuffer.append()方法就属于这类情况,如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步不范围(粗化)到整个操作序列的外部(例如,第一个append()操作之前知道最后一个appen()操作之后,这样只需要加锁一次就可以了)。

原子操作

原子操作就是“不可被中断的一个或一些列操作”。在并发编程中,原子操作可以说是最常见的一个术语。

处理器如何实现原子操作

首先处理器会自动保证基本的内存操作的原子性,处理器保证从系统内存中读取或者写入一个字节是原子的,意思就是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。
1、使用总线锁保证原子性
如果多个处理器同时对共享变量进行读后写操作(自增操作i++就是典型的读后写操作),那么共享变量就会被多个处理器同时进行操作,这样读后写操作就不是原子性的了,操作完之后共享变量的值会和期望的不一致。例如,初始化共享变量i=1,我们进行两次i++操作,我们期望的结果是3,但是可能结果是2。


所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器需要加锁操作共享内存时,该处理器在总线上输出此信号,那么其他处理器的请求将被阻塞住,那么该处理器就可以独占共享内存。

2、使用缓存锁定保证原子性

在同一时刻,我们只需要保证对某个内存地址的操作是原子性的即可,但总线锁把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销非常大,目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。

所谓缓存锁定是指,在最的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号。相反地,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据一个处理器的缓存回写到内存会导致其他处理器的缓存无效

Java如何实现原子操作

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

所谓CAS,简单来就是,CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B。这是一种乐观锁的思路,它相信在它修改之前,没有其它线程去修改它;类似数据库中乐观锁的版本控制机制。

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

  1. public class CASCounting {  
  2.     private static AtomicInteger atomicI=new AtomicInteger(0);  
  3.     private static int i=0;  
  4.     private static int si=0;  
  5.     //用CAS保证自增操作原子性  
  6.     private static void safeCount(){  
  7.         for(;;){  
  8.             int current=atomicI.get();  
  9.             int next=current+1;  
  10.             boolean suc=atomicI.compareAndSet(current, next);  
  11.             if(suc){  
  12.                 break;  
  13.             }  
  14.         }  
  15.     }  
  16.     //非原子性操作  
  17.     private static void unsafeCount(){  
  18.         i++;  
  19.     }  
  20.     //使用加锁同步保证原子性  
  21.     private synchronized static void synCount(){  
  22.         si++;  
  23.     }  
  24.     public static void main(String[] args){  
  25.         int count=1000;  
  26.         Thread[] threads=new Thread[count];  
  27.         long start=System.currentTimeMillis();  
  28.         for(int i=0;i<count;i++){  
  29.             threads[i]=new Thread(new Runnable(){  
  30.                 @Override  
  31.                 public void run(){  
  32.                     for(int i=0;i<1000;i++){  
  33.                         //safeCount();  
  34.                         //unsafeCount();  
  35.                         synCount();  
  36.                     }  
  37.                 }  
  38.             });  
  39.         }  
  40.         for(int i=0;i<count;i++){  
  41.             threads[i].start();  
  42.         }  
  43.         while(Thread.activeCount()>1){  
  44.             Thread.yield();  
  45.         }  
  46.         //System.out.println(atomicI.get());  
  47.         //System.out.println(i);  
  48.         System.out.println(si);  
  49.         System.out.println(System.currentTimeMillis()-start);   
  50.     }  
  51. }  
输出结果分别是:978018——93;1000000——110;1000000——218;

CAS实现原子操作的三大问题

ABA问题:因为CAS需要操作值的时候,检查值有没有变化,如果没有变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号,在变量前面追加一个版本号,每次变量更新的时候把版本号加1。

循环时间长开销大:自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。

只能保证一个共享变量的原子操作:对于多个共享变量的操作,循环CAS就无法保证操作的原子性了,这个时候就可以用锁。

2、使用锁机制实现原子操作

锁机制保证只有获得锁的线程才能够操作锁定的内存区域。JVM内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。有意思的是除了偏向锁,JVM实现锁的方式都用了循环CAS,即当一个线程想要进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候,使用循环CAS释放锁。

Java并发机制的实现

Java内存模型

Java线程之间的通信由Java内存模型(JMM,Java Memory Model)控制,JMM决定了一个线程的共享变量的写入何时对另一个线程可见。从抽象角度来看,JMM定义了线程和内存之间的抽象关系,线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存(也称工作内存),本地内存是JMM的一个抽象概念,并不是真实存在的。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。Java内存模型的抽象示意图如下:


重排序

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种

1、编译器优化的重排序,编译器在不改变单线程程序语义的前提下,可以重排序语句的执行顺序。

2、指令集并行的重排序,现代处理器采用了指令集并行技术(流水线技术)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

3、内存系统的重排序,由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去时在乱序执行。


重排序对多线程的影响

  1. public class ReorderExample {  
  2.     int a=0;  
  3.     boolean flag=false;  
  4.       
  5.     public void writer(){  
  6.         a=1;            //1  
  7.         flag=true;      //2  
  8.     }  
  9.       
  10.     public void reader(){  
  11.         if(flag){       //3  
  12.             int i=a*a;  //4  
  13.         }  
  14.     }  
  15. }  
flag变量是个标记,用来标识变量a是否已被写入。这里假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入呢?

答案是:不一定能看到。

由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。让我们先来看看,当操作1和操作2重排序时,可能会产生什么效果?请看下面的程序执行时序图:



操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还没有被线程A写入,在这里多线程程序的语义被重排序破坏了!

Volatile

实现原理

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

Volatile的特性

一个volatile变量的单个读/写操作,与一个普通变量的读/写操作都使用同一个锁来同步,他们之间的执行效果相同。

  1. public class VolatileFeature {  
  2.     volatile long vl=0l;  
  3.     public void set(long l){  
  4.         vl=l;  
  5.     }  
  6.     public void getAndIncrement(){  
  7.         vl++;  
  8.     }  
  9.     public long get(){  
  10.         return vl;  
  11.     }  
  12. }  
等价于
  1. public class VolatileFeature {  
  2.     long vl=0l;  
  3.     public synchronized void set(long l){  
  4.         vl=l;  
  5.     }  
  6.     //由于volatile变量的自增操作是一个复合操作,不能保证原子性  
  7.     public void getAndIncrement(){  
  8.         long temp=get();  
  9.         temp+=1l;  
  10.         set(temp);   
  11.     }  
  12.     public synchronized long get(){  
  13.         return vl;  
  14.     }  
  15. }  

volatile写-读的内存语意

当写一个volatile变量时,JMM会把该线程对应的本地内存(工作内存)中的共享变量刷新到贮存。


当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主存中读取共享变量。

使用volatile需要注意的问题

1、volatile关键字不能保证volatile变量复合操作的原子性

  1. public class VolatileCounting {  
  2.     private static volatile int count=0;  
  3.     private static void addCount(){  
  4.         count++;  
  5.     }  
  6.     public static void main(String[] args){  
  7.         int threadCount=1000;  
  8.         Thread[] threads=new Thread[threadCount];   
  9.         for(int i=0;i<threadCount;i++){  
  10.             threads[i]=new Thread(new Runnable(){  
  11.                 @Override  
  12.                 public void run(){  
  13.                     for(int j=0;j<1000;j++){  
  14.                         addCount();  
  15.                     }  
  16.                 }  
  17.             });  
  18.         }  
  19.         for(int i=0;i<threadCount;i++){  
  20.             threads[i].start();  
  21.         }  
  22.         while(Thread.activeCount()>1){  
  23.             Thread.yield();  
  24.         }  
  25.         System.out.println(count);   
  26.     }  
  27. }  

输出:996840

2、对64位long和double型变量的非原子性协定

java内存模型,允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行。如果有多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对他们进行读取或修改操作,那么某些线程可能会读取到一个即非原值,也不是其他线程修改值的“中间值”。

long和double占用的字节数都是8,也就是64bits。在64位操作系统上,JVM中double和long的赋值操作是原子操作。但是在32位操作系统上对64位的数据的读写要分两步完成,每一步取32位数据。这样对double和long的赋值操作就会有问题:如果有两个线程同时写一个变量内存,一个进程写低32位,而另一个写高32位,这样将导致获取的64位数据是失效的数据。因此需要使用volatile关键字来防止此类现象。volatile本身不保证获取和设置操作的原子性,仅仅保持修改的可见性。但是java的内存模型保证声明为volatile的long和double变量的get和set操作是原子的。

  1. public class LongVolatile {  
  2.     private static long value;  
  3.     private static void set0(){  
  4.         value=0;  
  5.     }  
  6.     private static void set1(){  
  7.         value=-1;  
  8.     }  
  9.     public static void main(String[] args) {  
  10.          System.out.println(Long.toBinaryString(-1));//-1的64位表示  
  11.          System.out.println(pad(Long.toBinaryString(0),64));//0的64位表示  
  12.          Thread t0=new Thread(new Runnable(){  
  13.              @Override  
  14.              public void run(){  
  15.                  set0();  
  16.              }  
  17.          });  
  18.          Thread t1=new Thread(new Runnable(){  
  19.              @Override  
  20.              public void run(){  
  21.                  set1();  
  22.              }  
  23.          });  
  24.          t0.start();  
  25.          t1.start();  
  26.          long temp;  
  27.          while ((temp = value) == -1 || temp == 0) {   
  28.          //如果静态成员value的值是-1或0,说明两个线程操作没有交叉  
  29.          }  
  30.          System.out.println(pad(Long.toBinaryString(temp), 64));  
  31.          System.out.println(temp);  
  32.   
  33.          t0.interrupt();  
  34.          t1.interrupt();  
  35.      }  
  36.      // 将0扩展  
  37.      private static String pad(String s, int targetLength) {  
  38.          int n = targetLength - s.length();  
  39.          for (int x = 0; x < n; x++) {  
  40.              s = "0" + s;  
  41.          }  
  42.          return s;  
  43.      }  
  44. }  
在32位操作系统上,我们开启两个线程,对long类型共享变量,不停的进行赋值操作,而主线程检测是否产生“中间值”。结果肯呢为:

1111111111111111111111111111111111111111111111111111111111111111
0000000000000000000000000000000000000000000000000000000000000000
0000000000000000000000000000000011111111111111111111111111111111
4294967295

或者

1111111111111111111111111111111111111111111111111111111111111111
0000000000000000000000000000000000000000000000000000000000000000
1111111111111111111111111111111100000000000000000000000000000000
-4294967296

如果我们将变量声明为volatile或者将程序在64位操作系统上运行,那么程序将进入死循环。由于赋值操作具有原子性,不会出现所谓的中间结果。

3、volatile可以禁止重排序

例如,利用volatile可以实现双重检验锁的单例模式。

Synchronized

实现原理

在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着Java SE 1.6对synchronized进行了各种优化之后,有些情况下它就并不那么重了。本文已经较为详细的介绍了Java SE 1.6中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。
先来看下利用synchronized实现同步的基础:Java中的每一个对象都可以作为锁。具体表现
为以下3种形式。
·对于普通同步方法,锁是当前实例对象。
·对于静态同步方法,锁是当前类的Class对象。
·对于同步方法块,锁是Synchonized括号里配置的对象。

锁的释放和获取的内存语义

当线程释放锁时,JVM会把该线程对应的本地内存(工作内存)中的共享变量刷新到主内存中。


当线程获取锁时,JVM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。


对比锁释放-获取的内存语义与volatile写-读的内存语义,可以看出:锁释放与volatile写有相同的内存语义;锁获取与volatile读有相同的内存语义。

final

final域的内存语义

写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化了,而普通域不具有这个保障。

读final域的重排序规则可以确保:在读取一个对象的final之前,一定会先读取包含这个final域的对象的引用。

内容源自:

《深入理解java虚拟机》

《Java并发编程的艺术》

展开阅读全文

没有更多推荐了,返回首页