精华内容
下载资源
问答
  • 主要介绍了详解Java程序并发的Wait-Notify机制,多线程并发是Java编程中的重要部分,需要的朋友可以参考下
  • Java并发机制及锁的实现原理

    万次阅读 多人点赞 2016-07-18 20:04:21
    Java并发机制及锁实现原理

    Java并发编程概述

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

    上下文切换

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

    多线程一定快吗

    下面的代码演示串行和并发执行并累加操作的时间,请分析:下面的代码并发执行一定比串行执行快吗?
    public class ConcurrentTest {
    	private static final long count=10001;
    	public static void main(String[] args) throws InterruptedException{
    		concurrency();
    		serial();
    	}
    	private static void concurrency() throws InterruptedException{
    		long start=System.currentTimeMillis();
    		Thread thread=new Thread(new Runnable(){
    			@Override
    			public void run(){
    				int a=0;
    				for(long i=0;i<count;i++){
    					a++;
    				}
    			}
    		});
    		thread.start();
    		int b=0;
    		for(long i=0;i<count;i++){
    			b--;
    		}
    		thread.join();
    		long time=System.currentTimeMillis()-start;
    		System.out.println("concurrency:"+time); 
    	}
    
    	private static void serial(){
    		long start=System.currentTimeMillis();
    		int a=0;
    		for(long i=0;i<count;i++){
    			a++;
    		}
    		int b=0;
    		for(long i=0;i<count;i++){
    			b--;;
    		}
    		long time=System.currentTimeMillis()-start;
    		System.out.println("serial:"+time); 
    	}
    }
    测试结果(具体数据与运行环境相关):

    循环次数

    串行执行耗时/ms

    并发执行/ms

    1万

    1

    2

    一百万

    7

    4

    一亿

    172

    90

     

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

    如何减少上下文切换

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

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

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

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

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

    死锁

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

     

    public class DeadLockDemo {
    	private static String A="A";
    	private static String B="B";
    	public static void main(String[] args){
    		new DeadLockDemo().deadLock();
    	} 
    	private void deadLock(){
    		Thread threadA=new Thread(new Runnable(){
    			@Override
    			public void run(){
    				synchronized(A){
    					try {
    						Thread.currentThread().sleep(2000);
    					} catch (InterruptedException e) { 
    						e.printStackTrace();
    					}
    					synchronized(B){
    						System.out.println("AB");
    					}
    				}
    			}
    		});
    		Thread threadB=new Thread(new Runnable(){
    			@Override
    			public void run(){
    				synchronized(B){
    					try {
    						Thread.currentThread().sleep(2000);
    					} catch (InterruptedException e) { 
    						e.printStackTrace();
    					}
    					synchronized(A){
    						System.out.println("BA");
    					}
    				}
    			}
    		});
    		threadA.start();
    		threadB.start();
    	}
    }

    一旦出现死锁,业务是可以感知的,因为不能继续提供服务了。那么,这个时候我们需要通过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来保障状态不变。

     

        private final int value;
    
        public Integer(int value) {
            this.value = value;
        }

    2、绝对线程安全

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

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

     

    public class VectorTest {
    	private static Vector<Integer> vector=new Vector<Integer>();
    	public static void main(String[] args){
    		while(true){
    			for(int i=0;i<10;i++){
    				vector.add(i);
    			}
    			Thread removeThread=new Thread(new Runnable(){
    				@Override
    				public void run(){
    					for(int i=0;i<vector.size();i++){
    						vector.remove(i);
    					}
    				}
    			});
    			Thread printThread=new Thread(new Runnable(){
    				@Override
    				public void run(){
    					for(int i=0;i<vector.size();i++){
    						System.out.println(vector.get(i));
    					}
    				}
    			});
    			removeThread.start();
    			printThread.start();
    			//不要同时产生过多的线程,否则会导致操作系统假死
    			while(Thread.activeCount()>20);
    		}
    
    	}
    }

     

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

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

     

    			Thread removeThread=new Thread(new Runnable(){
    				@Override
    				public void run(){
    					synchronized(vector){
    						for(int i=0;i<vector.size();i++){
    							vector.remove(i);
    						}
    					} 
    				}
    			});
    			Thread printThread=new Thread(new Runnable(){
    				@Override
    				public void run(){
    					synchronized(vector){
    						for(int i=0;i<vector.size();i++){
    							System.out.println(vector.get(i));
    						}
    					} 
    				}
    			});

     

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

    锁消除

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

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

     

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

     

     

    	public String concatString(String s1,String s2,String s3){ 
    		StringBuffer sb=new StringBuffer();
    		sb.append(s1);
    		sb.append(s2);
    		sb.append(s3);
    		return sb.toString();
    	}
    现在大家还认为这段代码没有同步吗?每个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操作直到成功为止。

     

    public class CASCounting {
    	private static AtomicInteger atomicI=new AtomicInteger(0);
    	private static int i=0;
    	private static int si=0;
    	//用CAS保证自增操作原子性
    	private static void safeCount(){
    		for(;;){
    			int current=atomicI.get();
    			int next=current+1;
    			boolean suc=atomicI.compareAndSet(current, next);
    			if(suc){
    				break;
    			}
    		}
    	}
    	//非原子性操作
    	private static void unsafeCount(){
    		i++;
    	}
    	//使用加锁同步保证原子性
    	private synchronized static void synCount(){
    		si++;
    	}
    	public static void main(String[] args){
    		int count=1000;
    		Thread[] threads=new Thread[count];
    		long start=System.currentTimeMillis();
    		for(int i=0;i<count;i++){
    			threads[i]=new Thread(new Runnable(){
    				@Override
    				public void run(){
    					for(int i=0;i<1000;i++){
    						//safeCount();
    						//unsafeCount();
    						synCount();
    					}
    				}
    			});
    		}
    		for(int i=0;i<count;i++){
    			threads[i].start();
    		}
    		while(Thread.activeCount()>1){
    			Thread.yield();
    		}
    		//System.out.println(atomicI.get());
    		//System.out.println(i);
    		System.out.println(si);
    		System.out.println(System.currentTimeMillis()-start); 
    	}
    }

    输出结果分别是: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、内存系统的重排序,由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去时在乱序执行。

    重排序对多线程的影响

     

    public class ReorderExample {
    	int a=0;
    	boolean flag=false;
    	
    	public void writer(){
    		a=1;			//1
    		flag=true;		//2
    	}
    	
    	public void reader(){
    		if(flag){		//3
    			int i=a*a;	//4
    		}
    	}
    }

    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变量的单个读/写操作,与一个普通变量的读/写操作都使用同一个锁来同步,他们之间的执行效果相同。

     

    public class VolatileFeature {
    	volatile long vl=0l;
    	public void set(long l){
    		vl=l;
    	}
    	public void getAndIncrement(){
    		vl++;
    	}
    	public long get(){
    		return vl;
    	}
    }

    等价于

     

    public class VolatileFeature {
    	long vl=0l;
    	public synchronized void set(long l){
    		vl=l;
    	}
    	//由于volatile变量的自增操作是一个复合操作,不能保证原子性
    	public void getAndIncrement(){
    		long temp=get();
    		temp+=1l;
    		set(temp); 
    	}
    	public synchronized long get(){
    		return vl;
    	}
    }

     

    volatile写-读的内存语意

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

     

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

    使用volatile需要注意的问题

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

     

    public class VolatileCounting {
    	private static volatile int count=0;
    	private static void addCount(){
    		count++;
    	}
    	public static void main(String[] args){
    		int threadCount=1000;
    		Thread[] threads=new Thread[threadCount]; 
    		for(int i=0;i<threadCount;i++){
    			threads[i]=new Thread(new Runnable(){
    				@Override
    				public void run(){
    					for(int j=0;j<1000;j++){
    						addCount();
    					}
    				}
    			});
    		}
    		for(int i=0;i<threadCount;i++){
    			threads[i].start();
    		}
    		while(Thread.activeCount()>1){
    			Thread.yield();
    		}
    		System.out.println(count); 
    	}
    }

     

    输出: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操作是原子的。

     

    public class LongVolatile {
    	private static long value;
    	private static void set0(){
    		value=0;
    	}
    	private static void set1(){
    		value=-1;
    	}
    	public static void main(String[] args) {
    		 System.out.println(Long.toBinaryString(-1));//-1的64位表示
    		 System.out.println(pad(Long.toBinaryString(0),64));//0的64位表示
    		 Thread t0=new Thread(new Runnable(){
    			 @Override
    			 public void run(){
    				 set0();
    			 }
    		 });
    		 Thread t1=new Thread(new Runnable(){
    			 @Override
    			 public void run(){
    				 set1();
    			 }
    		 });
    		 t0.start();
    		 t1.start();
    	     long temp;
    	     while ((temp = value) == -1 || temp == 0) { 
    	     //如果静态成员value的值是-1或0,说明两个线程操作没有交叉
    	     }
    	     System.out.println(pad(Long.toBinaryString(temp), 64));
    	     System.out.println(temp);
    
    	     t0.interrupt();
    	     t1.interrupt();
    	 }
    	 // 将0扩展
    	 private static String pad(String s, int targetLength) {
    		 int n = targetLength - s.length();
    		 for (int x = 0; x < n; x++) {
    			 s = "0" + s;
    	     }
    	     return s;
    	 }
    }

    在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并发编程的艺术》

    展开全文
  • Java 代码在编译后会变成 Java 字节码,字节码被类加载器加载到 JVM 里,JVM 执行字节码,最终需要转化为汇编指令在 CPU 上执行,Java 中所使用的并发机制依赖于 JVM 的实现和 CPU 的指令。本章我们将深入底层一起...

    前言:

    Java 代码在编译后会变成 Java 字节码,字节码被类加载器加载到 JVM 里,JVM 执行字节码,最终需要转化为汇编指令在 CPU 上执行,Java 中所使用的并发机制依赖于 JVM 的实现和 CPU 的指令。本章我们将深入底层一起探索下 Java 并发机制的底层实现原理。

    一、volatile 的应用

    在多线程并发编程中synchronizedvolatile都扮演着重要的角色volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。

    如果volatile变量修饰符使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。

    1.1 volatile 的定义与实现原理

    Java 语言规范第3版中对volatile的定义如下:

    Java 编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。

    Java 语言提供了volatile,在某些情况下比锁要更加方便。如果一个字段被声明成volatile,Java 线程内存模型确保所有线程看到这个变量的值是一致的。

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

    术语英文单词术语描述
    内存屏障memory barriers是一组处理器指令,用于实现对内存操作的顺序限制。
    缓冲行cache line缓存中可以分配的最小存储单位,处理器填写缓存行时会加载整个缓存行,需要使用多个主内存读周期。
    原子操作atomic operation不可中断的一个或一系列操作。
    缓存行填充cache line fill当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个缓存行到适当的缓存(L1,L2,L3 的或所有)。
    缓存命中cache hit如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存读取。
    写命中write hit当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存。
    写缺失write misses the cache一个有效的缓存行被写入到不存在的内存区域。

    volatile 是如何来保证可见性的呢?让我们在 X86 处理器下通过工具获取 JIT 编译器生成的汇编指令来查看对 volatile 进行写操作时,CPU 会做什么事情。

    Java代码如下。

    instance = new Singleton(); // instance是volatile变量
    

    转变成汇编代码,如下。

    0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl $0×0,(%esp);
    

    volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,通过查 IA-32 架构软件开发者手册可知,Lock 前缀的指令在多核处理器下会引发了两件事情。

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

    为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。

    如果对声明了volatile的变量进行写操作,JVM 就会向处理器发送一条 Lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。

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

    volatile的两条实现原则

    1. Lock前缀指令会引起处理器缓存回写到内存。
    2. 一个处理器的缓存回写到内存会导致其他处理器的缓存无效。

    1.2 volatile 的使用优化

    著名的 Java 并发编程大师 Doug lea 在 JDK 7 的并发包里新增一个队列集合类LinkedTransferQueue,它在使用volatile变量时,用一种追加字节的方式来优化队列出队和入队的性能。

    LinkedTransferQueue的代码如下。

    /** 队列中的头部节点 */
    private transient final PaddedAtomicReference<QNode> head;
    /** 队列中的尾部节点 */
    private transient final PaddedAtomicReference<QNode> tail;
    static final class PaddedAtomicReference<T> extends AtomicReference T>{
        // 使用很多4个字节的引用追加到64个字节
        Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;
        PaddedAtomicReference(T r) {
            super(r);
        }
    }
    public class AtomicReference<V> implements java.io.Serializable {
        private volatile V value;
        // 省略其他代码
    }
    

    让我们先来看看LinkedTransferQueue这个类,它使用一个内部类类型来定义队列的头节点(head)和尾节点(tail),而这个内部类PaddedAtomicReference相对于父类AtomicReference只做了一件事情,就是将共享变量追加到 64 字节。我们可以来计算下,一个对 象的引用占 4 个字节,它追加了 15 个变量(共占 60 个字节),再加上父类的 value 变量,一共 64 个字节。

    为什么追加64字节能够提高并发编程的效率呢?

    因为对于英特尔酷睿i7、酷睿、Atom 和 NetBurst,以及 Core Solo 和Pentium M 处理器的 L1、L2 或 L3 缓存的高速缓存行是 64 个字节宽,不支持部分填充缓存行,这意味着,如果队列的头节点和尾节点都不足 64 字节的话,处理器会将它们都读到同一个高速缓存行中,在多处理器下每个处理器都会缓存同样的头、尾节点,当一 个处理器试图修改头节点时,会将整个缓存行锁定,那么在缓存一致性机制的作用下,会导致其他处理器不能访问自己高速缓存中的尾节点,而队列的入队和出队操作则需要不停修改头节点和尾节点,所以在多处理器的情况下将会严重影响到队列的入队和出队效率。

    Doug lea 使用追加到64字节的方式来填满高速缓冲区的缓存行,避免头节点和尾节点加载到同一个缓存行,使头、尾节点在修改时不会互相锁定。

    那么是不是在使用volatile变量时都应该追加到64字节呢?

    不是的。在两种场景下不应该使用这种方式。

    1. 缓存行非 64 字节宽的处理器。

      如 P6 系列和奔腾处理器,它们的 L1 和 L2 高速缓存行是 32 个字节宽。

    2. 共享变量不会被频繁地写。

      因为使用追加字节的方式需要处理器读取更多的字节到高速缓冲区,这本身就会带来一定的性能消耗,如果共享变量不被频繁写的话,锁的几率也非常小,就没必要通过追加字节的方式来避免相互锁定。

    不过这种追加字节的方式在 Java 7 下可能不生效,因为 Java 7 变得更加智慧,它会淘汰或重新排列无用字段,需要使用其他追加字节的方式。

    二、synchronized 的实现原理与应用

    Java 中的每一个对象都可以作为锁。具体表现 为以下 3 种形式。

    • 对于普通同步方法,锁是当前实例对象。
    • 对于静态同步方法,锁是当前类的 Class 对象。
    • 对于同步代码块,锁是synchonized括号里配置的对象。

    当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。

    那么锁到底存在哪里呢?锁里面会存储什么信息呢?

    从 JVM 规范中可以看到synchonized在 JVM 里的实现原理,JVM 基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。代码块同步是使用monitorentermonitorexit指令实现的,而方法同步是使用另外一种方式实现的,细节在 JVM 规范里并没有详细说明。但是,方法的同步同样可以使用这两个指令来实现。

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

    2.1 Java 对象头

    synchronized用的锁是存在 Java 对象头里的。如果对象是数组类型,则虚拟机用 3 个字宽 (Word)存储对象头,如果对象是非数组类型,则用 2 字宽存储对象头。在 32 位虚拟机中,1 字宽等于 4 字节,即 32bit。

    Java 对象头的长度:

    长度内容说明
    32/64bitMark Word存储对象的 hashCode 或锁信息
    32/64bitClass Metadata Address存储到对象类型数据的指针
    32/64bitArray length数组的长度(如果当前对象是数组)

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

    Java 对象头的存储结构:

    锁状态25bit4bit1bit 是否是偏向锁2bit 锁标志位
    无锁状态对象的 hashCode对象分代年龄001

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

    Mark Word 的状态变化:
    Mark Word 的状态变化
    在64位虚拟机下,Mark Word是64bit大小的,其存储结构如表所示。

    Mark Word 的存储结构:Mark Word 的存储结构

    2.2 锁的升级与对比

    Java SE 1.6 为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在 Java SE 1.6 中,锁一共有 4 种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率,下文会详细分析。

    2.2.1 偏向锁

    大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。

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

    1. 偏向锁的撤销

      偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

      偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的 Mark Word 要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

      图中的线程 1 演示了偏向锁初始化的流程,线程 2 演示了偏向锁撤销的流程。

      偏向锁的获得和撤销流程

    2. 关闭偏向锁

      偏向锁在 Java 6 和 Java 7 里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用 JVM 参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。

    2.2.2 轻量级锁

    1. 轻量级加锁

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

    2. 轻量级锁解锁

      轻量级解锁时,会使用原子的 CAS 操作将 Displaced Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

      下图是两个线程同时争夺锁,导致锁膨胀的流程图。

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

    2.2.3 锁的优缺点对比

    优点缺点适用场景
    偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距。如果线程间存在锁竞争,会带来额外的锁撤销的消耗。适用于只有一个线程访问同步块场景。
    轻量级锁竞争的线程不会阻塞,提高了程序的响应速度。如果始终得不到锁竞争的线程,使用自旋会消耗CPU。追求响应时间,同步块执行速度非常快。
    重量级锁线程竞争不使用自旋,不会消耗CPU线程阻塞,响应时间缓慢。追求吞吐量,同步块执行速度较长。

    2.3 原子操作的实现原理

    原子(atomic)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为“不可被中断的一个或一系列操作”。在多处理器上实现原子操作就变得有点复杂。让我们一起来聊一聊在Intel处理器和Java里是如何实现原子操作的。

    2.3.1 CPU 术语定义

    术语名称英文解释
    缓存行Cache line缓存的最小单位
    比较并交换Compare and SwapCAS 操作需要输入两个值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较旧值有没有发生变化,才交换成新值,发生了变化则不交换。
    CPU 流水线CPU pipelineCPU 流水线的工作方式就像工业生产上的装配流水线,在 CPU 中由 5 ~ 6 个不同功能的电路单元组成一条指令处理流水线,然后将一条 X86 指令分成 5 ~ 6 步后再由这些电路单元分别执行,这样就能实现在一个 CPU 时钟周期完成一条指令,因此提高 CPU 的运算速度。
    内存顺序冲突Memory order violation内存顺序冲突一般是由假共享引起的,假共享是指多个 CPU 同时修改同一个缓存行的不同部分而引起其中一个 CPU 的操作无效,当出现这个内存顺序冲突时,CPU 必须清空流水线。

    2.3.2 处理器如何实现原子操作

    1. 使用总线锁保证原子性

      如果多个处理器同时对共享变量进行读改写操作(i++ 就是经典的读改写操作),那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一致。举个例子,如果 i=1,我们进行两次 i++ 操作,我们期望的结果是 3,但是有可能结果是 2,如图所示。

      结果对比
      原因可能是多个处理器同时从各自的缓存中读取变量 i,分别进行加 1 操作,然后分别写入系统内存中。那么,想要保证读改写共享变量的操作是原子的,就必须保证 CPU1 读改写共享变量的时候,CPU2 不能操作缓存了该共享变量内存地址的缓存。

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

    2. 使用缓存锁保证原子性

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

      频繁使用的内存会缓存在处理器的 L1、L2 和 L3 高速缓存里,那么原子操作就可以直接在处理器内部缓存中进行,并不需要声明总线锁,在 Pentium 6 和目前的处理器中可以使用 “缓存锁定” 的方式来实现复杂的原子性。

      所谓 “缓存锁定” 是指内存区域如果被缓存在处理器的缓存行中,并且在 Lock 操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言 LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效,在如图所示的例子中,当 CPU1 修改缓存行中的 i 时使用了缓存锁定,那么 CPU2 就不能同时缓存 i 的缓存行。

      但是有两种情况下处理器不会使用缓存锁定。

      第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line)时,则处理器会调用总线锁定。

      第二种情况是:有些处理器不支持缓存锁定。对于 Intel 486 和 Pentium 处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。

    针对以上两个机制,我们通过 Intel 处理器提供了很多 Lock 前缀的指令来实现。例如,位测试和修改指令:BTS、BTR、BTC;交换指令 XADD、CMPXCHG,以及其他一些操作数和逻辑指令(如ADD、OR)等,被这些指令操作的内存区域就会加锁,导致其他处理器不能同时访问它。

    2.3.3 Java如何实现原子操作

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

      JVM 中的 CAS 操作正是利用了处理器提供的 CMPXCHG 指令实现的。自旋 CAS 实现的基本思路就是循环进行 CAS 操作直到成功为止,以下代码实现了一个基于 CAS 线程安全的计数器方法 safeCount 和一个非线程安全的计数器 count。

      public class Counter {
      
          private final AtomicInteger atomicI = new AtomicInteger(0);
          private int i = 0;
      
          public static void main(String[] args) {
              final Counter cas = new Counter();
              List<Thread> ts = new ArrayList<>(600);
              long start = System.currentTimeMillis();
              for (int j = 0; j < 100; j++) {
                  Thread t = new Thread(() -> {
                      for (int i = 0; i < 10000; i++) {
                          cas.count();
                          cas.safeCount();
                      }
                  });
                  ts.add(t);
              }
              for (Thread t : ts)
                  t.start();
              for (Thread t : ts)
                  try {
                      t.join();
                  } catch (InterruptedException e) {
                      e.printStackTrace();
                  }
              System.out.println(cas.i); // 984453(不确定)
              System.out.println(cas.atomicI.get()); // 1000000
              System.out.println(System.currentTimeMillis() - start); // 173(不确定)
          }
      
          /**
           * 使用CAS实现线程安全计数器
           */
          private void safeCount() {
              while (true) {
                  int i = atomicI.get();
                  boolean suc = atomicI.compareAndSet(i, ++i);
                  if (suc)
                      break;
              }
          }
      
          /**
           * 非线程安全计数器
           */
          private void count() {
              i++;
          }
      
      }
      

      从 Java 1.5 开始,JDK 的并发包里提供了一些类来支持原子操作,如 AtomicBoolean(用原子方式更新的 boolean 值)、AtomicInteger(用原子方式更新的 int 值)和 AtomicLong(用原子方式更新的 long 值)。这些原子包装类还提供了有用的工具方法,比如以原子的方式将当前值自增 1 和自减 1。

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

      ABA 问题

      因为 CAS 需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是 A,变成了 B,又变成了 A,那么使用 CAS 进行检查时会发现它的值没有发生变化,但是实际上却变化了。

      ABA 问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加 1,那么 A→B→A 就会变成 1A→2B→3A。

      从 Java 1.5 开始,JDK 的 Atomic 包里提供了一个类 AtomicStampedReference 来解决 ABA 问题。这个类的 compareAndSet 方法的作用是首先检查当前引用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

      public boolean compareAndSet (
              V expectedReference, // 预期引用
              V newReference, // 更新后的引用
              int expectedStamp, // 预期标志
              int newStamp // 更新后的标志
      )
      

      循环时间长开销大

      自旋 CAS 如果长时间不成功,会给 CPU 带来非常大的执行开销。如果 JVM 能支持处理器提供的 pause 指令,那么效率会有一定的提升。

      pause 指令有两个作用:第一,它可以延迟流水线执行指令(de-pipeline),使 CPU 不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;第二,它可以避免在退出循环的时候因内存顺序冲突(Memory Order Violation)而引起 CPU 流水线被清空(CPU Pipeline Flush),从而提高CPU的执行效率。

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

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

      还有一个取巧的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量 i = 2,j = a,合并一下 ij = 2a,然后用 CAS 来操作 ij。从 Java 1.5 开始,JDK 提供了 AtomicReference 类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行 CAS 操作。

    3. 使用锁机制实现原子操作

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

    展开全文
  • java多线程并发机制

    千次阅读 2017-04-27 10:59:33
    进程:一个计算机程序的运行实例,包含了需要执行的指令;有自己的独立地址空间,包含程序内容和数据;不同进程的地址空间是互相隔离的;进程拥有各种资源和状态信息,包括打开的文件、子进程和信号处理。 线程:...

    一、多线程

    1、操作系统有两个容易混淆的概念,进程和线程。

    进程:一个计算机程序的运行实例,包含了需要执行的指令;有自己的独立地址空间,包含程序内容和数据;不同进程的地址空间是互相隔离的;进程拥有各种资源和状态信息,包括打开的文件、子进程和信号处理。

    线程:表示程序的执行流程,是CPU调度执行的基本单位;线程有自己的程序计数器、寄存器、堆栈和帧。同一进程中的线程共用相同的地址空间,同时共享进进程锁拥有的内存和其他资源。

    2、Java标准库提供了进程和线程相关的API,进程主要包括表示进程的Java.lang.Process类和创建进程的java.lang.ProcessBuilder类;

    表示线程的是java.lang.Thread类,在虚拟机启动之后,通常只有Java类的main方法这个普通线程运行,运行时可以创建和启动新的线程;还有一类守护线程(damon thread),守护线程在后台运行,提供程序运行时所需的服务。当虚拟机中运行的所有线程都是守护线程时,虚拟机终止运行。

    3、线程间的可见性:一个线程对进程中共享的数据的修改,是否对另一个线程可见

    可见性问题:

    a、CPU采用时间片轮转等不同算法来对线程进行调度

    public class IdGenerator{  
       private int value = 0;  
       public int getNext(){  
          return value++;  
       }  
    }  
    对于IdGenerator的getNext()方法,在多线程下不能保证返回值是不重复的:各个线程之间相互竞争CPU时间来获取运行机会,CPU切换可能发生在执行间隙。

    以上代码getNext()的指令序列:CPU切换可能发生在7条指令之间,多个getNext的指令交织在一起。

    aload_0  
    dup  
    getfield #12  
    dup_x1  
    iconst_1  
    iadd  
    putfield #12 
    b、CPU缓存:

    目前CPU一般采用层次结构的多级缓存的架构,有的CPU提供了L1、L2和L3三级缓存。当CPU需要读取主存中某个位置的数据时,会一次检查各级缓存中是否存在对应的数据。如果有,直接从缓存中读取,这比从主存中读取速度快很多。当CPU需要写入时,数据先被写入缓存中,之后再某个时间点写回主存。所以某些时间点上,缓存中的数据与主存中的数据可能是不一致。

    c、指令顺序重排

    出行性能考虑,编译器在编译时可能会对字节代码的指令顺序进行重新排列,以优化指令的执行顺序,在单线程中不会有问题,但在多线程可能产生与可见性相关的问题。


    二、Java内存模型(Java Memory Model)

    屏蔽了CPU缓存等细节,只关注主存中的共享变量;关注对象的实例域、静态域和数组元素;关注线程间的动作。

    1、volatile关键词:用来对共享变量的访问进行同步,上一次写入操作的结果对下一次读取操作是肯定可见的。(在写入volatile变量值之后,CPU缓存中的内容会被写回内存;在读取volatile变量时,CPU缓存中的对应内容会被置为失效,重新从主存中进行读取),volatile不使用锁,性能优于synchronized关键词。

    用来确保对一个变量的修改被正确地传播到其他线程中。

    2、final关键词
    final关键词声明的域的值只能被初始化一次,一般在构造方法中初始化。。(在多线程开发中,final域通常用来实现不可变对象)

    当对象中的共享变量的值不可能发生变化时,在多线程中也就不需要同步机制来进行处理,故在多线程开发中应尽可能使用不可变对象

    另外,在代码执行时,final域的值可以被保存在寄存器中,而不用从主存中频繁重新读取。

    3、java基本类型的原子操作

    1)基本类型,引用类型的复制引用是原子操作;(即一条指令完成)

    2)long与double的赋值,引用是可以分割的,非原子操作;

    3)要在线程间共享long或double的字段时,必须在synchronized中操作,或是声明成volatile


    三、Java提供的线程同步方式

    1、synchronized关键字

    方法或代码块的互斥性来完成实际上的一个原子操作。(方法或代码块在被一个线程调用时,其他线程处于等待状态)

    所有的Java对象都有一个与synchronzied关联的监视器对象(monitor),允许线程在该监视器对象上进行加锁和解锁操作。

    a、静态方法:Java类对应的Class类的对象所关联的监视器对象。

    b、实例方法:当前对象实例所关联的监视器对象。

    c、代码块:代码块声明中的对象所关联的监视器对象。

    注:当锁被释放,对共享变量的修改会写入主存;当活得锁,CPU缓存中的内容被置为无效。编译器在处理synchronized方法或代码块,不会把其中包含的代码移动到synchronized方法或代码块之外,从而避免了由于代码重排而造成的问题。

    例:以下方法getNext()和getNextV2() 都获得了当前实例所关联的监视器对象

    public class SynchronizedIdGenerator{  
       private int value = 0;  
       public synchronized int getNext(){  
          return value++;  
       }  
       public int getNextV2(){  
          synchronized(this){  
             return value++;  
          }  
       }  
    }  

    2、Object类的wait、notify和notifyAll方法

    生产者和消费者模式,判断缓冲区是否满来消费,缓冲区是否空来生产的逻辑。如果用while 和 volatile也可以做,不过本质上会让线程处于忙等待,占用CPU时间,对性能造成影响。

    wait: 将当前线程放入,该对象的等待池中,线程A调用了B对象的wait()方法,线程A进入B对象的等待池,并且释放B的锁。(这里,线程A必须持有B的锁,所以调用的代码必须在synchronized修饰下,否则直接抛出java.lang.IllegalMonitorStateException异常)。

    notify:将该对象中等待池中的线程,随机选取一个放入对象的锁池,当当前线程结束后释放掉锁, 锁池中的线程即可竞争对象的锁来获得执行机会。

    notifyAll:将对象中等待池中的线程,全部放入锁池。

    (notify锁唤醒的线程选择由虚拟机实现来决定,不能保证一个对象锁关联的等待集合中的线程按照所期望的顺序被唤醒,很可能一个线程被唤醒之后,发现他所要求的条件并没有满足,而重新进入等待池。因为当等待池中包含多个线程时,一般使用notifyAll方法,不过该方法会导致线程在没有必要的情况下被唤醒,之后又马上进入等待池,对性能有影响,不过能保证程序的正确性)

    工作流程:

    a、Consumer线程A 来 看产品,发现产品为空,调用产品对象的wait(),线程A进入产品对象的等待池并释放产品的锁。

    b、Producer线程B获得产品的锁,执行产品的notifyAll(),Consumer线程A从产品的等待池进入锁池,Producer线程B生产产品,然后退出释放锁。

    c、Consumer线程A获得产品锁,进入执行,发现有产品,消费产品,然后退出。

    例子:

    public synchronized String pop(){  
      this.notifyAll();// 唤醒对象等待池中的所有线程,可能唤醒的就是 生产者(当生产者发现产品满,就会进入对象的等待池,这里代码省略,基本略同)  
       while(index == -1){//如果发现没产品,就释放锁,进入对象等待池  
          this.wait();  
       }//当生产者生产完后,消费者从this.wait()方法再开始执行,第一次还会执行循环,万一产品还是为空,则再等待,所以这里必须用while循环,不能用if  
       String good = buffer[index];  
       buffer[index] = null;  
       index--;  
       return good;// 消费完产品,退出。  
    }  
    注:wait()方法有超时和不超时之分,超时的在经过一段时间,线程还在对象的等待池中,那么线程也会推出等待状态。

    3、线程状态转换:

    已经废弃的方法:stop、suspend、resume、destroy,这些方法在实现上时不安全的。

    线程的状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING(有超时的等待)、TERMINATED。

    a、方法sleep()进入的阻塞状态,不会释放对象的锁(即大家一起睡,谁也别想执行代码),所以不要让sleep方法处在synchronized方法或代码块中,否则造成其他等待获取锁的线程长时间处于等待。

    b、方法join()则是主线程等待子线程完成,再往下执行。例如main方法新建两个线程A和B

    public static void main(String[] args) throws InterruptedException {    
    Thread t1 = new Thread(new ThreadTesterA());    
    Thread t2 = new Thread(new ThreadTesterB());    
    t1.start();    
    t1.join(); // 等t1执行完再往下执行  
    t2.start();    
    t2.join(); // 在虚拟机执行中,这句可能被忽略  
    }  

    c、方法interrupt(),向被调用的对象线程发起中断请求。如线程A通过调用线程B的d的interrupt方法来发出中断请求,线程B来处理这个请求,当然也可以忽略,这不是必须的。Object类的wait()、Thread类的join()和sleep方法都会抛出受检异常java.lang.InterruptedException,通过interrupt方法中断该线程会导致线程离开等待状态。对于wait()调用来说,线程需要重新获取监视器对象上的锁之后才能抛出InterruptedException异常,并致以异常的处理逻辑。

    可以通过Thread类的isInterrupted方法来判断是否有中断请求发生,通常可以利用这个方法来判断是否退出线程(类似上面的volatitle修饰符的例子);

    Thread类还有个方法Interrupted(),该方法不但可以判断当前线程是否被中断,还会清楚线程内部的中断标记,如果返回true,即曾被请求中断,同时调用完后,清除中断标记。

    如果一个线程在某个对象的等待池,那么notify和interrupt 都可以使该线程从等待池中被移除。如果同时发生,那么看实际发生顺序。如果是notify先,那照常唤醒,没影响。如果是interrupt先,并且虚拟机选择让该线程中断,那么即使nofity,也会忽略该线程,而唤醒等待池中的另一个线程。

    e、yield(),尝试让出所占有的CPU资源,让其他线程获取运行机会,对操作系统上的调度器来说是一个信号,不一定立即切换线程。(在实际开发中,测试阶段频繁调用yeid方法使线程切换更频繁,从而让一些多线程相关的错误更容易暴露出来)。


    四、非阻塞方式

    线程之间同步机制的核心是监视对象上的锁,竞争锁来获得执行代码的机会。当一个对象获取对象的锁,然后其他尝试获取锁的对象会处于等待状态,这种锁机制的实现方式很大程度限制了多线程程序的吞吐量和性能(线程阻塞),且会带来死锁(线程A有a对象锁,等着获取b对象锁,线程B有b对象锁,等待获取a对象锁)和优先级倒置(优先级低的线程获得锁,优先级高的只能等待对方释放锁)等问题。

    如果能不阻塞线程,又能保证多线程程序的正确性,就能有更好的性能。

    在程序中,对共享变量的使用一般遵循一定的模式,即读取、修改和写入三步组成。之前碰到的问题是,这三步执行中可能线程执行切换,造成非原子操作。锁机制是把这三步变成一个原子操作。

    目前CPU本身实现 将这三步 合起来 形成一个原子操作,无需线程锁机制干预,常见的指令是“比较和替换”(compare and swap,CAS),这个指令会先比较某个内存地址的当前值是不是指定的旧指,如果是,就用新值替换,否则什么也不做,指令返回的结果是内存地址的当前值。通过CAS指令可以实现不依赖锁机制的非阻塞算法。一般做法是把CAS指令的调用放在一个无限循环中,不断尝试,知道CAS指令成功完成修改。

    java.util.concurrent.atomic包中提供了CAS指令。(不是所有CPU都支持CAS,在某些平台,java.util.concurrent.atomic的实现仍然是锁机制)

    atomic包中提供的Java类分成三类:

    1、支持以原子操作来进行更新的数据类型的Java类(AtomicBoolean、AtomicInteger、AtomicReference),在内存模型相关的语义上,这四个类的对象类似于volatile变量。

    类中的常用方法:

    a、compareAndSet:接受两个参数,一个是期望的旧值,一个是替换的新值。

    b、weakCompareAndSet:效果同compareAndSet(JSR中表示weak原子方式读取和有条件地写入变量但创建任何 happen-before 排序,但在源代码中和compareAndSet完全一样,所以并没有按JSR实现)

    c、get和set:分别用来直接获取和设置变量的值。

    d、lazySet:与set类似,但允许编译器把lazySet方法的调用与后面的指令进行重排,因此对值得设置操作有可能被推迟。

    例:

    public class AtomicIdGenerator{  
       private final AtomicInter counter = new AtomicInteger(0);  
       public int getNext(){  
          return counter.getAndIncrement();  
       }  
    }  
    // getAndIncrement方法的内部实现方式,这也是CAS方法的一般模式,CAS方法不一定成功,所以包装在一个无限循环中,直到成功  
    public final int getAndIncrement(){  
       for(;;){  
          int current = get();  
          int next = current +1;  
          if(compareAndSet(current,next))  
             return current;  
       }  
    }  

    2、提供对数组类型的变量进行处理的Java类,AtomicIntegerArray、AtomicLongArray和AtomicReferenceArray类。(同上,只是放在类数组里,调用时也只是多了一个操作元素索引的参数)

    3、通过反射的方式对任何对象中包含的volatitle变量使用CAS方法,AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater。他们提供了一种方式把CAS的功能扩展到了任何Java类中声明为volatitle的域上。(灵活,但语义较弱,因为对象的volatitle可能被非atomic的其他方式被修改)

    public class TreeNode{  
       private volatile TreeNode parent;  
    // 静态工厂方法  
       private static final AtomicReferenceFieldUpdater<TreeNode, TreeNode> parentUpdater = AtomicReferenceFieldUpdater.newUpdater(TreeNode.class,TreeNode.class,"parent");  
    public boolean compareAndSetParent(TreeNode expect, TreeNode update){  
          return parentUpdater.compareAndSet(this, expect, update);  
    }  
    }  

    注:java.util.concurrent.atomic包中的Java类属于比较底层的实现,一般作为java.util.concurrent包中很多非阻塞的数据结构的实现基础。

    比较多的用AtomicBoolean、AtomicInteger、AtomicLong和AtomicReference。在实现线程安全的计数器时,AtomicInteger和AtomicLong类时最佳的选择。

    五、高级同步机制(比synchronized更灵活的加锁机制)

    synchronized和volatile,以及wait、notify等方法抽象层次低,在程序开发中使用比较繁琐,易出错。

    而多线程之间的交互来说,存在某些固定的模式,如生产者-消费者和读者-写者模式,把这些模式抽象成高层API,使用起来会非常方便。

    java.util.concurrent包为多线程提供了高层的API,满足日常开发中的常见需求。

    常用接口

    1、Lock接口,表示一个锁方法:

    a、lock(),获取所,如果无法获取所锁,会处于等待状态

    b、unlock(),释放锁。(一般放在finally代码块中)

    c、lockInterruptibly(),与lock()类似,但允许当前线程在等待获取锁的过程中被中断。(所以要处理InterruptedException)

    d、tryLock(),以非阻塞方式获取锁,如果无法获取锁,则返回false。(tryLock()的另一个重载可以指定超时,如果指定超时,当无法获取锁,会等待而阻塞,同时线程可以被中断)

    2、ReadWriteLock接口,表示两个锁,读取的共享锁和写入的排他锁。(适合常见的读者--写者场景)

    ReadWriteLock接口的readLock和writeLock方法来获取对应的锁的Lock接口的实现。

    在多数线程读取,少数线程写入的情况下,可以提高多线程的性能,提高使用该数据结构的吞吐量。

    如果是相反的情况,较多的线程写入,则接口会降低性能。

    3、ReentrantLock类和ReentrantReadWriteLock,分别为上面两个接口的实现类。

    他们具有重入性:即允许一个线程多次获取同一个锁(他们会记住上次获取锁并且未释放的线程对象,和加锁的次数,getHoldCount())

    同一个线程每次获取锁,加锁数+1,每次释放锁,加锁数-1,到0,则该锁被释放,可以被其他线程获取。

    public class LockIdGenrator{  
    //new ReentrantLock(true)是重载,使用更加公平的加锁机制,在锁被释放后,会优先给等待时间最长的线程,避免一些线程长期无法获得锁  
       private int ReentrantLock lock = ReentrantLock();  
       privafte int value = 0;  
       public int getNext(){  
          lock.lock();      //进来就加锁,没有锁会等待  
          try{  
             return value++;//实际操作  
          }finally{  
             lock.unlock();//释放锁  
          }  
       }  
    }  
    注:重入性减少了锁在各个线程之间的等待,例如便利一个HashMap,每次next()之前加锁,之后释放,可以保证一个线程一口气完成便利,而不会每次next()之后释放锁,然后和其他线程竞争,降低了加锁的代价, 提供了程序整体的吞吐量。(即,让一个线程一口气完成任务,再把锁传递给其他线程)。
    4、Condition接口,Lock接口代替了synchronized,Condition接口替代了object的wait、nofity。

    a、await(),使当前线程进入等待状态,知道被唤醒或中断。重载形式可以指定超时时间。

    b、awaitNanos(),以纳秒为单位等待。

    c、awaitUntil(),指定超时发生的时间点,而不是经过的时间,参数为java.util.Date。

    d、awaitUninterruptibly(),前面几种会响应其他线程发出的中断请求,他会无视,直到被唤醒。

    注:与Object类的wait()相同,await()会释放其所持有的锁。

    e、signal()和signalAll, 相当于 notify和notifyAll

    Lock lock = new ReentrantLock();  
    Condition condition = lock.newCondition();  
    lock.lock();  
    try{  
       while(/*逻辑条件不满足*/){  
          condition.await();     
       }  
    }finally{  
       lock.unlock();  
    }  

    六、数据结构(多线程程序使用的高性能数据结构)

    java.util.concurrent包中提供了一些适合多线程程序使用的高性能数据结构,包括队列和集合类对象等。

    1、队列

    a、BlockingQueue接口:线程安全的阻塞式队列;当队列已满时,想队列添加会阻塞;当队列空时,取数据会阻塞。(非常适合消费者-生产者模式)

    阻塞方式:put()、take()。

    非阻塞方式:offer()、poll()。

    实现类:基于数组的固定元素个数的ArrayBolockingQueue和基于链表结构的不固定元素个数的LinkedBlockQueue类。

    b、BlockingDeque接口: 与BlockingQueue相似,但可以对头尾进行添加和删除操作的双向队列;方法分为两类,分别在队首和对尾进行操作。

    实现类:标准库值提供了一个基于链表的实现,LinkedBlockgingDeque

    2、集合类

    在多线程程序中,如果共享变量时集合类的对象,则不适合直接使用java.util包中的集合类。这些类要么不是线程安全,要么在多线程下性能比较差。

    应该使用java.util.concurrent包中的集合类。

    a、ConcurrentMap接口: 继承自java.util.Map接口

    putIfAbsent():只有在散列表不包含给定键时,才会把给定的值放入。

    remove():删除条目。

    replace(key,value):把value 替换到给定的key上。

    replace(key, oldvalue, newvalue):CAS的实现。

    实现类:ConcurrentHashMap

    创建时,如果可以预估可能包含的条目个数,可以优化性能。(因为动态调整所能包含的数目操作比较耗时,这个HashMap也一样,只是多线程下更耗时)。

    创建时,预估进行更新操作的线程数,这样实现中会根据这个数把内部空间划分为对应数量的部分。(默认是16,如果只有一个线程进行写操作,其他都是读取,那么把值设为1 可以提高性能)。

    注:当从集合中创建出迭代器遍历Map元素时,不一定能看到正在添加的数据,只能和集合保证弱一致性。(当然使用迭代器不会因为查看正在改变的Map,而抛出java.util.ConcurrentModifycationException)

    b、CopyOnWriteArrayList接口:继承自java.util.List接口。

    顾名思义,在CopyOnWriteArrayList的实现类,所有对列表的更新操作都会新创建一个底层数组的副本,并使用副本来存储数据;对列表更新操作加锁,读取操作不加锁。

    适合多读取少修改的场景,如果更新操作多,那么不适合用,同样迭代器只能表示创建时列表的状态,更新后使用了新的底层数组,迭代器还是引用旧的底层数组。


    七、多线程任务的执行

    过去线程的执行,是先创建Thread类的想,再调用start方法启动,这种做法要求开发人员对线程进行维护,在线程较多时,一般创建一个线程池同一管理,同时降低重复创建线程的开销

    在J2SE5.0中,java.util.concurrent包提供了丰富的用来管理线程和执行任务的实现。

    1、基本接口(描述任务)

    a、Callable接口:

    Runnable接口受限于run方法的类型签名,而Callable只有一个方法call(),可以有返回值,可以抛出受检异常。

    b、Future接口:

    过去,需要异步线程的任务执行结果,要求主线程和任务执行线程之间进行同步和数据传递。

    Future简化了任务的异步执行,作为异步操作的一个抽象。调用get()方法可以获取异步的执行结果,如果任务没有执行完,会等待,直到任务完成或被取消,cancel()可以取消。

    c、Delayed接口:

    延迟执行任务,getDelay()返回当前剩余的延迟时间,如果不大于0,说明延迟时间已经过去,应该调度并执行该任务。

    2、组合接口(描述任务)

    a、RunnableFuture接口:继承自Runnable接口和Future接口。

    当来自Runnalbe接口中的run方法成功执行之后,相当于Future接口表示的异步任务已经完成,可以通过get()获取运行结果。

    b、ScheduledFuture接口:继承Future接口和Delayed接口,表示一个可以调用的异步操作。

    c、RunnableScheduledFuture接口:继承自Runnable、Delayed和Future,接口中包含isPeriodic,表明该异步操作是否可以被重复执行。

    3、Executor接口、ExcutorServer接口、ScheduleExecutorService接口和CompletionService接口(描述任务执行)

    a、executor接口,execute()用来执行一个Runnable接口的实现对象,不同的Executor实现采取不同执行策略,但提供的任务执行功能比较弱。

    b、excutorServer接口,继承自executor;

    提供了对任务的管理:submit(),可以吧Callable和Runnable作为任务提交,得到一个Future作为返回,可以获取任务结果或取消任务。

    提供批量执行:invokeAll()和invokeAny(),同时提交多个Callable;invokeAll(),会等待所有任务都执行完成,返回一个包含每个任务对应Future的列表;invokeAny(),任何一个任务成功完成,即返回该任务结果。

    提供任务关闭:shutdown()、shutdownNow()来关闭服务,前者不允许新的任务提交,后者试图终止正在运行和等待的任务,并返回已经提交单没有被运行的任务列表。(两个方法都不会等待服务真正关闭,只是发出关闭请求。)。shutdownDow,通常做法是向线程发出中断请求,所以确保提交的任务实现了正确的中断处理逻辑。

    c、ScheduleExecutorService接口,继承自excutorServer接口:支持任务的延迟执行和定期执行,可以执行Callable或Runnable。

    schedule(),调度一个任务在延迟若干时间之后执行;

    scheduleAtFixedRate():在初始延迟后,每隔一段时间循环执行;在下一次执行开始时,上一次执行可能还未结束。(同一时间,可能有多个)

    scheduleWithFixedDelay:同上,只是在上一次任务执行完后,经过给定的间隔时间再开始下一次执行。(同一时间,只有一个)

    以上三个方法都返回ScheduledFuture接口的实现对象。

    d、CompletionService接口,共享任务执行结果。

    通常在使用ExecutorService接口,通过submit提交任务,并得到一个Future接口来获取任务结果,如果任务提交者和执行结果的使用者是程序的不同部分,那就要把Future在不同部分进行传递;而CompletionService就是解决这个问题,程序不同部分可以共享CompletionService,任务提交后,执行结果可以通过take(阻塞),poll(非阻塞)来获取。

    标准库提供的实现是 ExecutorCompletionService,在创建时,需要提供一个Executor接口的实现作为参数,用来实际执行任务。

    例:多线程方式下载文件

    public class FileDownloader{  
       // 线程池  
       private final ExecutorService executor = Executors.newFixedThreadPool(10);  
       public boolean download(final URL url, final Path path){  
       Future<Path> future = executor.submit(new Callable<Path>(){ //submit提交任务  
          public Path call(){  
             //这里就省略IOException的处理了  
             InputStream is = url.openStream();  
             Files.copy(is, path, StandardCopyOption.REPLACE_EXISTING);  
             return path;  
          });  
          try{  
             return future.get() !=null ? true : false;  
          }<span style="font-family: Arial, Helvetica, sans-serif;">catch(InterruptedException | ExecutionException e){</span>  
                 return false;  
          }  
       }  
       public void close(){//当不再使用FileDownloader类的对象时,应该使用close方法关闭其中包含的ExecutorService接口的实现对象,否则虚拟机不会退出,占用内存不释放  
          executor.shutdown();// 发出关闭请求,此时不会再接受新任务  
          try{  
             if(!executor.awaitTermination(3, TimeUnit.MINUTES)){// awaitTermination 来等待一段时间,使正在执行的任务或等待的任务有机会完成  
                executor.shutdownNow();// 如果等待时间过后还有任务没完成,则强制结束  
                executor.awaitTermination(1, TimeUnit.MINUTES);// 再等待一段时间,使被强制结束的任务完成必要的清理工作  
             }  
          }catch(InterruptedException e){  
             executor.shutdownNow();  
             Thread.currentThread().interrupt();  
          }  
       }  
    }  

    Java SE 7 新特性

    对java.util.concurrent包进行更新,增加了新的轻量级任务执行框架fork/join和多阶段线程同步工具。

    1、轻量级任务执行框架fork/join

    这个框架的目的主要是更好地利用底层平台上的多核和多处理器来进行并行处理。

    通过分治算法或map/reduce算法来解决问题。

    fork/join 类比于 map/reduce。

    fork操作是把一个大的问题划分为若干个较小的问题,划分过程一般为递归,直到可以直接进行计算的粒度适合的子问题;子问题在结算后,可以得到整个问题的部分解

    join操作收集子结果,合并,得到完整解,也可能是 递归进行的。

    相对一般的线程池实现,F/J框架的优势在任务的处理方式上。在一般线程池中,一个线程由于某些原因无法运行,会等待;而在F/J,某个子问题由于等待另外一个子问题的完成而无法继续运行,那么处理该子问题的线程会主动寻找其他尚未运行的子问题来执行。这种方式减少了等待时间,提高了性能。

    为了F/J能高效,在每个子问题视线中应避免使用synchronized或其他方式进行同步,也不应使用阻塞式IO或过多访问共享变量。在理想情况下,每个子问题都应值进行CPU计算,只使用每个问题的内部对象,唯一的同步应只发生在子问题和创建它的父问题之间。(这完全就是Hadoop的MapReduce嘛)

    a、ForkJoinTask类:表示一个由F/J框架执行的任务,该类实现了Future接口,可以按照Future接口的方式来使用。(表示任务)

    fork(),异步方式启动任务的执行。

    join(),等待任务完成并返回执行结果。

    在创建自己的任务时,最好不要直接继承自ForkJoinTask,而是继承其子类,RecuriveTask或RecursiveAction,前者可以返回结果,后者不行。

    b、ForkJoinPool类:表示任务执行,实现了ExecutorService接口,除了可以执行ForkJoinTask,也可以执行Callable和Runnable。(任务执行)

    执行任务的两大类:

    第一类:execute、invoke或submit方法:直接提交任务。

    第二类:fork():运行ForkJoinTask在执行过程中的子任务。

    一般作法是表示整个问题的ForkJoinTask用第一类提交,执行过程中产生的子任务不需要处理,ForkJoinPool会负责子任务执行。

    例:查找数组中的最大值

    private static class MaxValueTask extends RecursiveTask<Long>{  
       private final long[] array;  
       private final int start;  
       private final int end;  
       MaxValueTask(long[] array, int start, int end){  
          this.array = array;  
          this.start = start;  
          this.end = end;  
       }  
       //compute是RecursiveTask的主方法  
       protected long compute(){  
          long max = Long.MIN_VALUE;  
          if(end - start < RANG_LENGTH){//寻找最大值  
             for(int i = start; i<end;i++{  
                if(array[i] > max){  
                   max = array[i];  
                }  
             }  
          }else{// 二分任务  
             int mid = (start + end) /2;  
             MaxValueTask lowTask = new MaxValueTask(array, start , mid);  
             MaxValueTask highTask = new MaxValueTask(array, mid, end);  
             lowTask.fork();// 异步启动任务  
             highTask.fork();  
             max = Math.max(max, lowTask.join());//等待执行结果  
             max = Math.max(max, highTask.join();  
          }  
          return max;  
       }  
       public Long calculate(long[] array){  
          MaxValueTask task = new MaxValueTask(array, 0 , array.length);  
          Long result = forkJoinPool.invoke(task);  
          return result;  
       }  
    }  

    注:这个例子是示例,但从性能上说直接对整个数组顺序比较效率高,毕竟多线程所带来的额外开销过大。

    在实际中,F/J框架发挥作用的场合很多,比如在一个目录包含的所有文本中搜索某个关键字,可以每个文件创建一个子任务。

    如果相关的功能可以用递归和分治来解决,就适合F/J。

    2、多阶段线程同步工具

    Phaser类是Java SE 7中新增的一个使用同步工具,功能和灵活性比倒数闸门和循环屏障要强很多。

    在F/J框架中的子任务之间要进行同步时,应优先考虑Phaser。

    Phaser把多个线程写作执行的任务划分成多个阶段(phase),编程时要明确各个阶段的任务,每个阶段都可以有任意个参与者,线程可以随时注册并参与到某个阶段,当一个阶段中所有线程都成功完成之后,Phaser的onAdvance()被调用,可以通过覆盖添加自定义处理逻辑(类似循环屏障的使用的Runnable接口),然后Phaser类会自动进入下个阶段。如此循环,知道Phaser不再包含任何参与者。

    Phaser创建后,初始阶段编号为0,构造函数中指定初始参与个数。

    register(),bulkRegister(),动态添加一个或多个参与者。

    arrive(),某个参与者完成任务后调用

    arriveAndDeregister(),任务完成,取消自己的注册。

    arriveAndAwaitAdvance(),自己完成等待其他参与者完成。,进入阻塞,直到Phaser成功进入下个阶段。

    awaitAdvance()、awaitAdvanceInterruptibly(),等待phaser进入下个阶段,参数为当前阶段的编号,后者可以设置超时和处理中断请求。

    另外,Phaser的一个重要特征是多个Phaser可以组成树形结构,Phaser提供了构造方法来指定当前对象的父对象;当一个子对象参与者>0,会自动注册到父对象中;当=0,自动解除注册。

    例:从指定网址,下载img标签的照片

    阶段1、处理网址对应的html文本,和抽取img的链接;2、创建图片下载子线程,主线程等待;3、子线程下载图片,主线程等待;4、任务完成退出

    public class WebPageImageDownloader{  
       private final Phaser phaser = new Phaser(1);//初始参与数1,代表主线程。  
       public void download(URL url, final Path path) throws IOException{  
          String content = getContent(url);//获得HTML文本,省略。  
          List<URL> imageUrls = extractImageUrls(content);//获得图片链接,省略。  
          for(final URL imageUrl : imageUrls){  
             phaser.register();//子线程注册  
             new Thread(){  
                public void run(){  
                   phaser.arriveAndAwaitAdvance();//第二阶段的等待,等待进入第三阶段  
                   try{  
                      InputStream is = imageUrl.openStream();  
                      File.copy(is, getSavePath(path, imageUrl), StandardCopyOption.REPLACE_EXISTING);  
                   }catch(IOException e){  
                      e.printStackTrace():  
                   }finally{  
                      phaser.arriveAndDeregister();//子线程完成任务,退出。  
                   }  
                }  
            }.start();  
          }  
          phaser.arriveAndAwaitAdvance();//第二阶段等待,子线程在注册  
          phaser.arriveAndAwaitAdvance();//第三阶段等待,子线程在下载  
          phaser.arriveAndDeregister();//所有线程退出。  
       }  
    }  

    九、ThreadLocal类

    java.lang.ThreadLocal,线程局部变量,把一个共享变量变为一个线程的私有对象。不同线程访问一个ThreadLocal类的对象时,锁访问和修改的事每个线程变量各自独立的对象。通过ThreadLocal可以快速把一个非线程安全的对象转换成线程安全的对象。(同时也就不能达到数据传递的作用了)。

    a、get()和set()分别用来获取和设置当前线程中包含的对象的值。

    b、remove(),删除。

    c、initialValue(),初始化值。如果没有通过set方法设置值,第一个调用get,会通过initValue来获取对象的初始值。

    ThreadLoacl的一般用法,创建一个ThreadLocal的匿名子类并覆盖initalValue(),把ThreadLoacl的使用封装在另一个类中

    public class ThreadLocalIdGenerator{  
       private static final ThreadLocal<IdGenerator> idGenerator = new ThreadLocal<IdGenerator>(){  
             protected IdGenerator initalValue(){  
                return new IdGenerator();//IdGenerator 是个初始int value =0,然后getNext(){  return value++}  
             }  
          };  
       public static int getNext(){  
          return idGenerator.get().getNext();  
       }  
    }  

    ThreadLoal的另外一个作用是创建线程唯一的对象,在有些情况,一个对象在代码中各个部分都需要用到,传统做法是把这个对象作为参数在代码间传递,如果使用这个对I昂的代码都在同一个线程,可以封装在ThreadLocal中。

    如:在多线程中,生成随机数

    java.util.Random会带来竞争问题,java.util.concurrent.ThreadLocalRandom类提供多线程下的随机数声场,底层是ThreadLoacl。


    总结:多线程开发中应该优先使用高层API,如果无法满足,使用java.util.concurrent.atomic和java.util.concurrent.locks包提供的中层API,而synchronized和volatile,以及wait,notify和notifyAll等低层API 应该最后考虑。

    好文推荐:Java中的多线程你只要看这一篇就够了

    展开全文
  • 深入理解Java并发之synchronized实现原理

    万次阅读 多人点赞 2017-06-04 17:44:44
    【版权申明】未经博主同意,谢绝转载!(请尊重原创,博主保留追究权) ... 出自【zejian的博客】...深入理解Java类型信息(Class对象)与反射机制 深入理解Java枚举类型(enum) 深入理解Java注解类型(@Annotation) 深...

    【版权申明】未经博主同意,谢绝转载!(请尊重原创,博主保留追究权)
    http://blog.csdn.net/javazejian/article/details/72828483
    出自【zejian的博客】

    关联文章:

    深入理解Java类型信息(Class对象)与反射机制

    深入理解Java枚举类型(enum)

    深入理解Java注解类型(@Annotation)

    深入理解Java类加载器(ClassLoader)

    深入理解Java并发之synchronized实现原理

    Java并发编程-无锁CAS与Unsafe类及其并发包Atomic

    深入理解Java内存模型(JMM)及volatile关键字

    剖析基于并发AQS的重入锁(ReetrantLock)及其Condition实现原理

    剖析基于并发AQS的共享锁的实现(基于信号量Semaphore)

    并发之阻塞队列LinkedBlockingQueue与ArrayBlockingQueue

    本篇主要是对Java并发中synchronized关键字进行较为深入的探索,这些知识点结合博主对synchronized的个人理解以及相关的书籍的讲解(在结尾参考资料),如有误处,欢迎留言。

    线程安全是并发编程中的重要关注点,应该注意到的是,造成线程安全问题的主要诱因有两点,一是存在共享数据(也称临界资源),二是存在多条线程共同操作共享数据。因此为了解决这个问题,我们可能需要这样一个方案,当存在多个线程操作共享数据时,需要保证同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再进行,这种方式有个高尚的名称叫互斥锁,即能达到互斥访问目的的锁,也就是说当一个共享数据被当前正在访问的线程加上互斥锁后,在同一个时刻,其他线程只能处于等待的状态,直到当前线程处理完毕释放该锁。在 Java 中,关键字 synchronized可以保证在同一个时刻,只有一个线程可以执行某个方法或者某个代码块(主要是对方法或者代码块中存在共享数据的操作),同时我们还应该注意到synchronized另外一个重要的作用,synchronized可保证一个线程的变化(主要是共享数据的变化)被其他线程所看到(保证可见性,完全可以替代Volatile功能),这点确实也是很重要的。

    synchronized的三种应用方式

    synchronized关键字最主要有以下3种应用方式,下面分别介绍

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

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

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

    synchronized作用于实例方法

    所谓的实例对象锁就是用synchronized修饰实例对象中的实例方法,注意是实例方法不包括静态方法,如下

    public class AccountingSync implements Runnable{
        //共享资源(临界资源)
        static int i=0;
    
        /**
         * synchronized 修饰实例方法
         */
        public synchronized void increase(){
            i++;
        }
        @Override
        public void run() {
            for(int j=0;j<1000000;j++){
                increase();
            }
        }
        public static void main(String[] args) throws InterruptedException {
            AccountingSync instance=new AccountingSync();
            Thread t1=new Thread(instance);
            Thread t2=new Thread(instance);
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            System.out.println(i);
        }
        /**
         * 输出结果:
         * 2000000
         */
    }

    上述代码中,我们开启两个线程操作同一个共享资源即变量i,由于i++;操作并不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成,如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,这也就造成了线程安全失败,因此对于increase方法必须使用synchronized修饰,以便保证线程安全。此时我们应该注意到synchronized修饰的是实例方法increase,在这样的情况下,当前线程的锁便是实例对象instance,注意Java中的线程同步锁可以是任意对象。从代码执行结果来看确实是正确的,倘若我们没有使用synchronized关键字,其最终输出结果就很可能小于2000000,这便是synchronized关键字的作用。这里我们还需要意识到,当一个线程正在访问一个对象的 synchronized 实例方法,那么其他线程不能访问该对象的其他 synchronized 方法,毕竟一个对象只有一把锁,当一个线程获取了该对象的锁之后,其他线程无法获取该对象的锁,所以无法访问该对象的其他synchronized实例方法,但是其他线程还是可以访问该实例对象的其他非synchronized方法,当然如果是一个线程 A 需要访问实例对象 obj1 的 synchronized 方法 f1(当前对象锁是obj1),另一个线程 B 需要访问实例对象 obj2 的 synchronized 方法 f2(当前对象锁是obj2),这样是允许的,因为两个实例对象锁并不同相同,此时如果两个线程操作数据并非共享的,线程安全是有保障的,遗憾的是如果两个线程操作的是共享数据,那么线程安全就有可能无法保证了,如下代码将演示出该现象

    public class AccountingSyncBad implements Runnable{
        static int i=0;
        public synchronized void increase(){
            i++;
        }
        @Override
        public void run() {
            for(int j=0;j<1000000;j++){
                increase();
            }
        }
        public static void main(String[] args) throws InterruptedException {
            //new新实例
            Thread t1=new Thread(new AccountingSyncBad());
            //new新实例
            Thread t2=new Thread(new AccountingSyncBad());
            t1.start();
            t2.start();
            //join含义:当前线程A等待thread线程终止之后才能从thread.join()返回
            t1.join();
            t2.join();
            System.out.println(i);
        }
    }

    上述代码与前面不同的是我们同时创建了两个新实例AccountingSyncBad,然后启动两个不同的线程对共享变量i进行操作,但很遗憾操作结果是1452317而不是期望结果2000000,因为上述代码犯了严重的错误,虽然我们使用synchronized修饰了increase方法,但却new了两个不同的实例对象,这也就意味着存在着两个不同的实例对象锁,因此t1和t2都会进入各自的对象锁,也就是说t1和t2线程使用的是不同的锁,因此线程安全是无法保证的。解决这种困境的的方式是将synchronized作用于静态的increase方法,这样的话,对象锁就当前类对象,由于无论创建多少个实例对象,但对于的类对象拥有只有一个,所有在这样的情况下对象锁就是唯一的。下面我们看看如何使用将synchronized作用于静态的increase方法。

    synchronized作用于静态方法

    当synchronized作用于静态方法时,其锁就是当前类的class对象锁。由于静态成员不专属于任何一个实例对象,是类成员,因此通过class对象锁可以控制静态 成员的并发操作。需要注意的是如果一个线程A调用一个实例对象的非static synchronized方法,而线程B需要调用这个实例对象所属类的静态 synchronized方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的class对象,而访问非静态 synchronized 方法占用的锁是当前实例对象锁,看如下代码

    public class AccountingSyncClass implements Runnable{
        static int i=0;
    
        /**
         * 作用于静态方法,锁是当前class对象,也就是
         * AccountingSyncClass类对应的class对象
         */
        public static synchronized void increase(){
            i++;
        }
    
        /**
         * 非静态,访问时锁不一样不会发生互斥
         */
        public synchronized void increase4Obj(){
            i++;
        }
    
        @Override
        public void run() {
            for(int j=0;j<1000000;j++){
                increase();
            }
        }
        public static void main(String[] args) throws InterruptedException {
            //new新实例
            Thread t1=new Thread(new AccountingSyncClass());
            //new心事了
            Thread t2=new Thread(new AccountingSyncClass());
            //启动线程
            t1.start();t2.start();
    
            t1.join();t2.join();
            System.out.println(i);
        }
    }

    由于synchronized关键字修饰的是静态increase方法,与修饰实例方法不同的是,其锁对象是当前类的class对象。注意代码中的increase4Obj方法是实例方法,其对象锁是当前实例对象,如果别的线程调用该方法,将不会产生互斥现象,毕竟锁对象不同,但我们应该意识到这种情况下可能会发现线程安全问题(操作了共享静态变量i)。

    synchronized同步代码块

    除了使用关键字修饰实例方法和静态方法外,还可以使用同步代码块,在某些情况下,我们编写的方法体可能比较大,同时存在一些比较耗时的操作,而需要同步的代码又只有一小部分,如果直接对整个方法进行同步操作,可能会得不偿失,此时我们可以使用同步代码块的方式对需要同步的代码进行包裹,这样就无需对整个方法进行同步操作了,同步代码块的使用示例如下:

    public class AccountingSync implements Runnable{
        static AccountingSync instance=new AccountingSync();
        static int i=0;
        @Override
        public void run() {
            //省略其他耗时操作....
            //使用同步代码块对变量i进行同步操作,锁对象为instance
            synchronized(instance){
                for(int j=0;j<1000000;j++){
                        i++;
                  }
            }
        }
        public static void main(String[] args) throws InterruptedException {
            Thread t1=new Thread(instance);
            Thread t2=new Thread(instance);
            t1.start();t2.start();
            t1.join();t2.join();
            System.out.println(i);
        }
    }
    

    从代码看出,将synchronized作用于一个给定的实例对象instance,即当前实例对象就是锁对象,每次当线程进入synchronized包裹的代码块时就会要求当前线程持有instance实例对象锁,如果当前有其他线程正持有该对象锁,那么新到的线程就必须等待,这样也就保证了每次只有一个线程执行i++;操作。当然除了instance作为对象外,我们还可以使用this对象(代表当前实例)或者当前类的class对象作为锁,如下代码:

    //this,当前实例对象锁
    synchronized(this){
        for(int j=0;j<1000000;j++){
            i++;
        }
    }
    
    //class对象锁
    synchronized(AccountingSync.class){
        for(int j=0;j<1000000;j++){
            i++;
        }
    }

    了解完synchronized的基本含义及其使用方式后,下面我们将进一步深入理解synchronized的底层实现原理。

    synchronized底层语义原理

    Java 虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现, 无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块)还是隐式同步都是如此。在 Java 语言中,同步用的最多的地方可能是被 synchronized 修饰的同步方法。同步方法 并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的,关于这点,稍后详细分析。下面先来了解一个概念Java对象头,这对深入理解synchronized实现原理非常关键。

    理解Java对象头与Monitor

    在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下:

    • 实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。

    • 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。

    而对于顶部,则是Java头对象,它实现synchronized的锁对象的基础,这点我们重点分析它,一般而言,synchronized使用的锁对象是存储在Java对象头里的,jvm中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),其主要结构是由Mark Word 和 Class Metadata Address 组成,其结构说明如下表:

    虚拟机位数头对象结构说明
    32/64bitMark Word存储对象的hashCode、锁信息或分代年龄或GC标志等信息
    32/64bitClass Metadata Address类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。

    其中Mark Word在默认情况下存储着对象的HashCode、分代年龄、锁标记位等以下是32位JVM的Mark Word默认存储结构

    锁状态25bit4bit1bit是否是偏向锁2bit 锁标志位
    无锁状态对象HashCode对象分代年龄001

    由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,如32位JVM下,除了上述列出的Mark Word默认存储结构外,还有如下可能变化的结构:

    其中轻量级锁和偏向锁是Java 6 对 synchronized 锁进行优化后新增加的,稍后我们会简要分析。这里我们主要分析一下重量级锁也就是通常说synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下(位于HotSpot虚拟机源码ObjectMonitor.hpp文件,C++实现的)

    ObjectMonitor() {
        _header       = NULL;
        _count        = 0; //记录个数
        _waiters      = 0,
        _recursions   = 0;
        _object       = NULL;
        _owner        = NULL;
        _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
        _WaitSetLock  = 0 ;
        _Responsible  = NULL ;
        _succ         = NULL ;
        _cxq          = NULL ;
        FreeNext      = NULL ;
        _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
        _SpinFreq     = 0 ;
        _SpinClock    = 0 ;
        OwnerIsThread = 0 ;
      }

    ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示

    由此看来,monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因(关于这点稍后还会进行分析),ok~,有了上述知识基础后,下面我们将进一步分析synchronized在字节码层面的具体语义实现。

    synchronized代码块底层原理

    现在我们重新定义一个synchronized修饰的同步代码块,在代码块中操作共享变量i,如下

    public class SyncCodeBlock {
    
       public int i;
    
       public void syncTask(){
           //同步代码库
           synchronized (this){
               i++;
           }
       }
    }

    编译上述代码并使用javap反编译后得到字节码如下(这里我们省略一部分没有必要的信息):

    Classfile /Users/zejian/Downloads/Java8_Action/src/main/java/com/zejian/concurrencys/SyncCodeBlock.class
      Last modified 2017-6-2; size 426 bytes
      MD5 checksum c80bc322c87b312de760942820b4fed5
      Compiled from "SyncCodeBlock.java"
    public class com.zejian.concurrencys.SyncCodeBlock
      minor version: 0
      major version: 52
      flags: ACC_PUBLIC, ACC_SUPER
    Constant pool:
      //........省略常量池中数据
      //构造函数
      public com.zejian.concurrencys.SyncCodeBlock();
        descriptor: ()V
        flags: ACC_PUBLIC
        Code:
          stack=1, locals=1, args_size=1
             0: aload_0
             1: invokespecial #1                  // Method java/lang/Object."<init>":()V
             4: return
          LineNumberTable:
            line 7: 0
      //===========主要看看syncTask方法实现================
      public void syncTask();
        descriptor: ()V
        flags: ACC_PUBLIC
        Code:
          stack=3, locals=3, args_size=1
             0: aload_0
             1: dup
             2: astore_1
             3: monitorenter  //注意此处,进入同步方法
             4: aload_0
             5: dup
             6: getfield      #2             // Field i:I
             9: iconst_1
            10: iadd
            11: putfield      #2            // Field i:I
            14: aload_1
            15: monitorexit   //注意此处,退出同步方法
            16: goto          24
            19: astore_2
            20: aload_1
            21: monitorexit //注意此处,退出同步方法
            22: aload_2
            23: athrow
            24: return
          Exception table:
          //省略其他字节码.......
    }
    SourceFile: "SyncCodeBlock.java"

    我们主要关注字节码中的如下代码

    3: monitorenter  //进入同步方法
    //..........省略其他  
    15: monitorexit   //退出同步方法
    16: goto          24
    //省略其他.......
    21: monitorexit //退出同步方法

    从字节码中可知同步语句块的实现使用的是monitorenter 和 monitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor (关于重入性稍后会分析),重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。值得注意的是编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。从字节码中也可以看出多了一个monitorexit指令,它就是异常结束时被执行的释放monitor 的指令。

    synchronized方法底层原理

    方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词), 然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。下面我们看看字节码层面如何实现:

    public class SyncMethod {
    
       public int i;
    
       public synchronized void syncTask(){
               i++;
       }
    }

    使用javap反编译后的字节码如下:

    Classfile /Users/zejian/Downloads/Java8_Action/src/main/java/com/zejian/concurrencys/SyncMethod.class
      Last modified 2017-6-2; size 308 bytes
      MD5 checksum f34075a8c059ea65e4cc2fa610e0cd94
      Compiled from "SyncMethod.java"
    public class com.zejian.concurrencys.SyncMethod
      minor version: 0
      major version: 52
      flags: ACC_PUBLIC, ACC_SUPER
    Constant pool;
    
       //省略没必要的字节码
      //==================syncTask方法======================
      public synchronized void syncTask();
        descriptor: ()V
        //方法标识ACC_PUBLIC代表public修饰,ACC_SYNCHRONIZED指明该方法为同步方法
        flags: ACC_PUBLIC, ACC_SYNCHRONIZED
        Code:
          stack=3, locals=1, args_size=1
             0: aload_0
             1: dup
             2: getfield      #2                  // Field i:I
             5: iconst_1
             6: iadd
             7: putfield      #2                  // Field i:I
            10: return
          LineNumberTable:
            line 12: 0
            line 13: 10
    }
    SourceFile: "SyncMethod.java"

    从字节码中可以看出,synchronized修饰的方法并没有monitorenter指令和monitorexit指令,取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这便是synchronized锁在同步代码块和同步方法上实现的基本原理。同时我们还必须注意到的是在Java早期版本中,synchronized属于重量级锁,效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因。庆幸的是在Java 6之后Java官方对从JVM层面对synchronized较大优化,所以现在的synchronized锁效率也优化得很不错了,Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,引入了轻量级锁和偏向锁,接下来我们将简单了解一下Java官方在JVM层面对synchronized锁的优化。

    Java虚拟机对synchronized的优化

    锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级,关于重量级锁,前面我们已详细分析过,下面我们将介绍偏向锁和轻量级锁以及JVM的其他优化手段,这里并不打算深入到每个锁的实现和转换过程更多地是阐述Java虚拟机所提供的每个锁的核心优化思想,毕竟涉及到具体过程比较繁琐,如需了解详细过程可以查阅《深入理解Java虚拟机原理》。

    偏向锁

    偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。下面我们接着了解轻量级锁。

    轻量级锁

    倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

    自旋锁

    轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。

    锁消除

    消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。

    /**
     * Created by zejian on 2017/6/4.
     * Blog : http://blog.csdn.net/javazejian [原文地址,请尊重原创]
     * 消除StringBuffer同步锁
     */
    public class StringBufferRemoveSync {
    
        public void add(String str1, String str2) {
            //StringBuffer是线程安全,由于sb只会在append方法中使用,不可能被其他线程引用
            //因此sb属于不可能共享的资源,JVM会自动消除内部的锁
            StringBuffer sb = new StringBuffer();
            sb.append(str1).append(str2);
        }
    
        public static void main(String[] args) {
            StringBufferRemoveSync rmsync = new StringBufferRemoveSync();
            for (int i = 0; i < 10000000; i++) {
                rmsync.add("abc", "123");
            }
        }
    
    }

    关于synchronized 可能需要了解的关键点

    synchronized的可重入性

    从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功,在java中synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。如下:

    public class AccountingSync implements Runnable{
        static AccountingSync instance=new AccountingSync();
        static int i=0;
        static int j=0;
        @Override
        public void run() {
            for(int j=0;j<1000000;j++){
    
                //this,当前实例对象锁
                synchronized(this){
                    i++;
                    increase();//synchronized的可重入性
                }
            }
        }
    
        public synchronized void increase(){
            j++;
        }
    
    
        public static void main(String[] args) throws InterruptedException {
            Thread t1=new Thread(instance);
            Thread t2=new Thread(instance);
            t1.start();t2.start();
            t1.join();t2.join();
            System.out.println(i);
        }
    }

    正如代码所演示的,在获取当前实例对象锁后进入synchronized代码块执行同步代码,并在代码块中调用了当前实例对象的另外一个synchronized方法,再次请求当前实例锁时,将被允许,进而执行方法体代码,这就是重入锁最直接的体现,需要特别注意另外一种情况,当子类继承父类时,子类也是可以通过可重入锁调用父类的同步方法。注意由于synchronized是基于monitor实现的,因此每次重入,monitor中的计数器仍会加1。

    线程中断与synchronized

    线程中断

    正如中断二字所表达的意义,在线程运行(run方法)中间打断它,在Java中,提供了以下3个有关线程中断的方法

    //中断线程(实例方法)
    public void Thread.interrupt();
    
    //判断线程是否被中断(实例方法)
    public boolean Thread.isInterrupted();
    
    //判断是否被中断并清除当前中断状态(静态方法)
    public static boolean Thread.interrupted();

    当一个线程处于被阻塞状态或者试图执行一个阻塞操作时,使用Thread.interrupt()方式中断该线程,注意此时将会抛出一个InterruptedException的异常,同时中断状态将会被复位(由中断状态改为非中断状态),如下代码将演示该过程:

    public class InterruputSleepThread3 {
        public static void main(String[] args) throws InterruptedException {
            Thread t1 = new Thread() {
                @Override
                public void run() {
                    //while在try中,通过异常中断就可以退出run循环
                    try {
                        while (true) {
                            //当前线程处于阻塞状态,异常必须捕捉处理,无法往外抛出
                            TimeUnit.SECONDS.sleep(2);
                        }
                    } catch (InterruptedException e) {
                        System.out.println("Interruted When Sleep");
                        boolean interrupt = this.isInterrupted();
                        //中断状态被复位
                        System.out.println("interrupt:"+interrupt);
                    }
                }
            };
            t1.start();
            TimeUnit.SECONDS.sleep(2);
            //中断处于阻塞状态的线程
            t1.interrupt();
    
            /**
             * 输出结果:
               Interruted When Sleep
               interrupt:false
             */
        }
    }

    如上述代码所示,我们创建一个线程,并在线程中调用了sleep方法从而使用线程进入阻塞状态,启动线程后,调用线程实例对象的interrupt方法中断阻塞异常,并抛出InterruptedException异常,此时中断状态也将被复位。这里有些人可能会诧异,为什么不用Thread.sleep(2000);而是用TimeUnit.SECONDS.sleep(2);其实原因很简单,前者使用时并没有明确的单位说明,而后者非常明确表达秒的单位,事实上后者的内部实现最终还是调用了Thread.sleep(2000);,但为了编写的代码语义更清晰,建议使用TimeUnit.SECONDS.sleep(2);的方式,注意TimeUnit是个枚举类型。ok~,除了阻塞中断的情景,我们还可能会遇到处于运行期且非阻塞的状态的线程,这种情况下,直接调用Thread.interrupt()中断线程是不会得到任响应的,如下代码,将无法中断非阻塞状态下的线程:

    public class InterruputThread {
        public static void main(String[] args) throws InterruptedException {
            Thread t1=new Thread(){
                @Override
                public void run(){
                    while(true){
                        System.out.println("未被中断");
                    }
                }
            };
            t1.start();
            TimeUnit.SECONDS.sleep(2);
            t1.interrupt();
    
            /**
             * 输出结果(无限执行):
                 未被中断
                 未被中断
                 未被中断
                 ......
             */
        }
    }

    虽然我们调用了interrupt方法,但线程t1并未被中断,因为处于非阻塞状态的线程需要我们手动进行中断检测并结束程序,改进后代码如下:

    public class InterruputThread {
        public static void main(String[] args) throws InterruptedException {
            Thread t1=new Thread(){
                @Override
                public void run(){
                    while(true){
                        //判断当前线程是否被中断
                        if (this.isInterrupted()){
                            System.out.println("线程中断");
                            break;
                        }
                    }
    
                    System.out.println("已跳出循环,线程中断!");
                }
            };
            t1.start();
            TimeUnit.SECONDS.sleep(2);
            t1.interrupt();
    
            /**
             * 输出结果:
                线程中断
                已跳出循环,线程中断!
             */
        }
    }

    是的,我们在代码中使用了实例方法isInterrupted判断线程是否已被中断,如果被中断将跳出循环以此结束线程,注意非阻塞状态调用interrupt()并不会导致中断状态重置。综合所述,可以简单总结一下中断两种情况,一种是当线程处于阻塞状态或者试图执行一个阻塞操作时,我们可以使用实例方法interrupt()进行线程中断,执行中断操作后将会抛出interruptException异常(该异常必须捕捉无法向外抛出)并将中断状态复位,另外一种是当线程处于运行状态时,我们也可调用实例方法interrupt()进行线程中断,但同时必须手动判断中断状态,并编写中断线程的代码(其实就是结束run方法体的代码)。有时我们在编码时可能需要兼顾以上两种情况,那么就可以如下编写:

    public void run(){
        try {
        //判断当前线程是否已中断,注意interrupted方法是静态的,执行后会对中断状态进行复位
        while (!Thread.interrupted()) {
            TimeUnit.SECONDS.sleep(2);
        }
        } catch (InterruptedException e) {
    
        }
    }

    中断与synchronized

    事实上线程的中断操作对于正在等待获取的锁对象的synchronized方法或者代码块并不起作用,也就是对于synchronized来说,如果一个线程在等待锁,那么结果只有两种,要么它获得这把锁继续执行,要么它就保存等待,即使调用中断线程的方法,也不会生效。演示代码如下

    /**
     * Created by zejian on 2017/6/2.
     * Blog : http://blog.csdn.net/javazejian [原文地址,请尊重原创]
     */
    public class SynchronizedBlocked implements Runnable{
    
        public synchronized void f() {
            System.out.println("Trying to call f()");
            while(true) // Never releases lock
                Thread.yield();
        }
    
        /**
         * 在构造器中创建新线程并启动获取对象锁
         */
        public SynchronizedBlocked() {
            //该线程已持有当前实例锁
            new Thread() {
                public void run() {
                    f(); // Lock acquired by this thread
                }
            }.start();
        }
        public void run() {
            //中断判断
            while (true) {
                if (Thread.interrupted()) {
                    System.out.println("中断线程!!");
                    break;
                } else {
                    f();
                }
            }
        }
    
    
        public static void main(String[] args) throws InterruptedException {
            SynchronizedBlocked sync = new SynchronizedBlocked();
            Thread t = new Thread(sync);
            //启动后调用f()方法,无法获取当前实例锁处于等待状态
            t.start();
            TimeUnit.SECONDS.sleep(1);
            //中断线程,无法生效
            t.interrupt();
        }
    }

    我们在SynchronizedBlocked构造函数中创建一个新线程并启动获取调用f()获取到当前实例锁,由于SynchronizedBlocked自身也是线程,启动后在其run方法中也调用了f(),但由于对象锁被其他线程占用,导致t线程只能等到锁,此时我们调用了t.interrupt();但并不能中断线程。

    等待唤醒机制与synchronized

    所谓等待唤醒机制本篇主要指的是notify/notifyAll和wait方法,在使用这3个方法时,必须处于synchronized代码块或者synchronized方法中,否则就会抛出IllegalMonitorStateException异常,这是因为调用这几个方法前必须拿到当前对象的监视器monitor对象,也就是说notify/notifyAll和wait方法依赖于monitor对象,在前面的分析中,我们知道monitor 存在于对象头的Mark Word 中(存储monitor引用指针),而synchronized关键字可以获取 monitor ,这也就是为什么notify/notifyAll和wait方法必须在synchronized代码块或者synchronized方法调用的原因。

    synchronized (obj) {
           obj.wait();
           obj.notify();
           obj.notifyAll();         
     }

    需要特别理解的一点是,与sleep方法不同的是wait方法调用完成后,线程将被暂停,但wait方法将会释放当前持有的监视器锁(monitor),直到有线程调用notify/notifyAll方法后方能继续执行,而sleep方法只让线程休眠并不释放锁。同时notify/notifyAll方法调用后,并不会马上释放监视器锁,而是在相应的synchronized(){}/synchronized方法执行结束后才自动释放锁。

    ok~,篇幅已比较长了,关于synchronized,我们就暂且聊到这。如有错误,欢迎指正,谢谢~~。

    本篇的主要参考资料:
    《Java编程思想》
    《深入理解Java虚拟机》
    《实战Java高并发程序设计》

    感觉文章给你带来收获就赞一个吧!

    展开全文
  • Java程序运行机制及其运行过程

    万次阅读 多人点赞 2018-07-31 13:34:46
    本文主要讲的是jvm运行java程序。 (一)终端中如何运行一个java程序(这个是我在mac下运行的,windows下原理是一样的,大同小异) 做这个事情的前提下,一定是jdk已经安装好了并且没任何问题。 首先要想运行java...
  • JAVA并发机制的底层实现原理

    千次阅读 2016-08-10 16:17:09
    JAVA代码在编译后会变成字节码,字节码被类加载器加载到JVM中,JVM执行字节码,最终需要转化为汇编指令在CPU上执行,JAVA中所使用的并发机制依赖于JVM的实现和CPU的指令。 1.volatile的应用volatile是轻量级的...
  • 研究了程序并发过程中的同步机制和交互通信机制,比较了基于操作系统级和基于Java多线程级并发机制的实现结构,总结了并发程序中死锁预防的一些编程规则和策略。所构造的一个具有完全意义上的并发同步的框架实例有...
  • 前言并发编程的目的是让程序运行更快,但是使用并发并不定会使得程序运行更快,只有当程序并发数量达到一定的量级的时候才能体现并发编程的优势。所以谈并发编程在高并发量的时候才有意义。虽然目前还没有开发过高...
  • 如果一个字段被声明成volatile,java线程内存模型确保所有的线程看到这个变量的值是一致的。 前置知识 CPU术语定义 术语 英文单词 术语描述 内存屏障 memory barriers 是一组处理器指令,用于实现对内
  • 0x01a3de1d: movb $0×0,0×1104800(%esi);0x01a3de24: lock addl $0×0,(%esp);
  • Java基础——线程及并发机制

    千次阅读 2017-12-05 10:37:32
    线程又存在并发,并发机制的原理是什么。这些内容有些了解,有些又不是很清楚,所以有必要通过一篇文章的梳理,弄清其中的来龙去脉,为了之后的开发过程中提供更好的支持。 目录 一、线程定义
  • 并发_02_JAVA并发机制的底层实现原理

    万次阅读 2018-07-29 09:18:43
    一、并发编程中的三个概念 1.原子性 原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。 2.可见性 可见性是指当多个线程访问同一个变量时,一个线程修改了...
  • 前言在Java并发编程中最重要的就是两个关键字volatile和synchronized,其中volatile可以说是轻量级的synchronized,它可以保证共享变量的可见性,而且由于不需要切换线上下文,所以执行成本比synchronized更低。...
  • Java并发机制(1)--理论基础

    千次阅读 2017-10-25 14:16:14
    进程与线程概念 在现代操作系统中,进程支持多线程。 进程是资源管理的最小单元, 线程是程序执行的最小单元。 ...线程作为调度和分配的基本单位,进程作为资源分配的基本单位。...计算机采用多道程序设计模型...
  • Java并发--InterruptedException机制

    万次阅读 2018-06-03 18:57:18
    原创声明:本文系作者原创,谢绝个人、媒体、公众号或网站未经授权转载,违者追究其法律责任。...java中断响应是描述当一个线程或方法A处于运行、阻塞或死锁状态时,外界(通常指其他线程、系统I...
  • java 并发编程

    2014-12-10 13:59:14
     《Java7并发编程实战手册》是Java7并发编程的实战指南,介绍了Java7并发API中大部分重要而有用的机制。全书分为9章,涵盖了线程管理、线程同步、线程执行器、Fork/Join框架、并发集合、定制并发类、测试并发应用等...
  • java并发的处理--锁机制

    千次阅读 2017-12-27 11:34:10
    对于我们开发的网站,如果网站的访问量非常大的话,那么我们就需要考虑相关的并发访问问题了。而并发问题是绝大部分的程序员头疼的问题, 但话又说回来了,既然逃避不掉,那我们就坦然面对吧~今天就让我们一起来...
  • 并发编程篇:java并发面试题

    万次阅读 多人点赞 2018-02-28 21:43:18
    cas Java中Unsafe类详解 12、线程池 线程池的作用: 在程序启动的时候就创建若干线程来响应处理,它们被称为线程池,里面的线程叫工作线程 第一:降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的...
  • Java并发系列2-并发

    千次阅读 2019-07-06 17:25:02
    Java并发系列2-并发锁 接着上一篇并发文章我们继续 Java并发系列1-开篇 本篇的主要内容是以下几点: wait 、notify 的简单使用 Reentrantlock的简单使用 synchronized 与Reentrantlock的区别 ThreadLocal的...
  • java 7并发编程实战手册》是java 7并发编程的实战指南,介绍了java 7并发api中大部分重要而有用的机制。全书分为9章,涵盖了线程管理、线程同步、线程执行器、fork/join框架、并发集合、定制并发类、测试并发应用...
  • Java 并发之基础线程机制

    千次阅读 2018-09-26 20:18:23
    Java语言不同于C++语言,是一种单根继承结构语言,也就是说,Java中所有的类都有一个共同的祖先。这个祖先就是Object类。 object类的结构如下: 如图可知,Object类有12个成员方法,按照用途可以分为以下几种 1...
  • 在实际工作中,错误使用多线程非但不能提高效率还可能使程序崩溃。以在路上开车为例: 在一个单向行驶的道路上,每辆汽车都遵守交通规则,这时候整体通行是正常的。『单向车道』意味着『一个线程』,『多辆车』意味...
  • 对于我们开发的网站,如果网站的访问量非常大的话,我们就...所谓同步,可以理解为在执行完一个函数或方法之后,一直等待系统返回值或消息,这时程序是处于阻塞状态的,只有接受到返回值或消息之后才往下执行其他的...
  • 《死磕 Java 并发编程》系列连载中,大家可以关注一波: ????????『死磕Java并发编程系列』 01 十张图告诉你多线程那些破事 『死磕Java并发编程系列』 02 面试官:说说什么是Java内存模型? 『死磕Java并发编程...
  • java并发

    千次阅读 多人点赞 2018-02-02 18:11:32
    并发
  • 关于Java并发编程的总结和思考

    万次阅读 多人点赞 2015-03-27 10:36:10
    Java语言从第一版本开始内置了对多线程的支持,这一点在当年是非常了不起的,但是当我们对并发编程有了更深刻的认识和更多的实践后,实现并发编程就有了更多的方案和更好的选择。本文是对并发编程的一点总结和思考,...
  • java并发编程实践

    2012-06-23 02:34:01
    JAVA并发编程实践》并不仅仅提供并发API的清单及其机制,还提供了设计原则、模式和思想模型,使我们能够更好地构建正确的、性能良好的并发程序。《JAVA并发编程实践》适合于具有一定Java编程经验的程序员、希望...

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 233,871
精华内容 93,548
关键字:

java程序的并发机制是

java 订阅