《深入理解Java虚拟机》读书笔记7

一、Java内存模型

       Java内存模型(Java Memory Model, JMM),用来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。主要目标是定义程序中各个共享变量的访问规则。

1、主内存与工作内存

       Java内存模型规定所有(线程共享)变量都存储在主内存(Main Memory)中。每条线程还有自己的工作内存(Working Memory)。线程工作内存中保存了被该线程使用到的共享变量的拷贝副本,线程对共享变量的所有操作都必须在工作内存中进行。线程间共享变量的值的传递均需要通过主内存来完成。

       线程、主内存、工作内存三者的交互关系如下图所示:

《《深入理解Java虚拟机》读书笔记7》

注:这里的主内存、工作内存与Java运行时内存区域并不是一个层次的内存划分。勉强对应的话,主内存主要对应于Java堆中对象实例数据部分,工作内存对应于虚拟机栈中的部分区域。

二、内存间交互操作

1、内存模型中的八种基本操作

      对于主内存与工作内存之间如何交互。java内存模型中定义了以下八种操作来完成:(注意:虚拟机实现时必须保证这里的每一种操作都是原子的、不可再分的)。

      <1>、lock(锁定):作用于主内存的变量,将一个变量标识为一条线程独占的状态。

      <2>、unlock(解锁):作用于主内存的变量,将一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

      <3>、read(读取):作用于主内存的变量,将一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。

      <4>、load(载入):作用于工作内存的变量,将read操作从主内存中得到的变量值放入工作内存的变量副本中。

      <5>、use(使用):作用于工作内存的变量,将工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的字节码指令时将会执行这个操作。

      <6>、assign(赋值):作用于工作内存变量,将一个从执行引擎接收到的值赋值给工作内存的变量(每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作)。

      <7>、store(存储):作用于工作内存的变量,将工作内存中一个变量的值传送到主内存,以便随后的write操作使用。

      <8>、write(写入):作用于主内存变量,将store操作从工作内存中得到的变量的值放入主内存的变量中。

Java内存模型规定执行上述八种基本操作需要满足以下条件:

      <1>、不允许read和load、store和write操作之一单独出现;

      <2>、不允许一个线程丢弃它最近的assign操作;

      <3>、不允许一个线程无原因把数据从线程工作内存同步回主内存;

      <4>、不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量;

      <5>、一个变量在同一时刻只允许一个线程对其进行lock操作;

      <6>、如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用该变量前,需要重新执行load或assign操作初始化变量。

      <7>、如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;

      <8>、对一个变量执行unlock操作之前,必须先把此变量同步回主内存中。

2、volatile关键字的语义

       被定义成volatile的变量,将具备两种特性:对所有线程的“可见性” 和 禁止指令重排序优化。

       <1>、对所有线程的“可见性”是:Java保证当一条线程对volatile变量的值做修改后,新值对于其他线程来说是可以立即得知的。(通过如下规则实现:第一、线程每次使用volatile变量之前必须先从主内存刷新最新的值,用来保证能看到其他线程对该变量所做的修改后的值,第二、线程每次修改volatile变量后必须立即同步到主内存中,保证其他线程可以看到对变量的修改)。

       针对volatile变量的“可见性”特性,适合如下场景的并发控制:

volatile boolean shutdownRequested;
	
	public void shutdown() {
		shutdownRequested = true;
	}
	
	public void doWork() {
		while (!shutdownRequested) {
			// do stuff
		}
	}

     注:上例代码中当shutdown()方法被调用时,能保证所有线程中执行的doWork()方法都立刻停下来。

     <2>、禁止指令重排列优化。普通变量不能保证变量的赋值操作顺序与程序中代码中的执行顺序一致,因为可能存在指令重排列的优化。而声明了volatile关键字的变量是“禁止指令重排列的”,可以避免这种情况。

3、对于long和double型变量的特殊规则

      Java内存模型对于64位的数据类型(long和double),有所谓的非原子性协议(Nonatomic Treatment of double and long Variables)。即允许虚拟机将没有volatile修饰的64位数据读写操作划分为两次32位的操作来进行。也就是说不能保证64位数据类型的load、store、read和write这四个操作的原子性。

      注:虚拟机规范“强烈建议”虚拟机实现将64位数据读写作为原子操作对待。目前几乎所有的虚拟机实现也是这么做的。

4、原子性、可见性与有序性

        原子性(Atomicity):可以认为基本数据类型的访问读写是具备原子性的。更大范围的原子性保证是通过synchronized关键字,synchronized块之间的操作也具备原子性。

        可见性(Visibility):普通共享变量与volatile变量的区别是volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新,因此volatile能保证多线程操作时变量的可见性,而普通变量则不能保证这一点。Java中除了volatile关键字之外,还有两个关键字能实现可见性,它们是synchronized和final。final变量的“可见性”是:被final修饰的字段在构造器中一旦被初始化完成,那么在其他线程中就能看见final字段的值,因此final变量无须同步就能被其他线程正确地访问。

        有序性(Ordering):总结为:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行语义(Within-Thread As-If-Serial Semantics)”,后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性。

        注:synchronized的“万能”也间接造成了被滥用的局面,这通常会伴随着越来越大的性能影响。

5、先行发生原则

       “先行发生(happens-before)”是Java内存模型中定义的两项操作之间的偏虚关系,如果说操作A先行发生于操作B,其实就是说发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。

       Java内存模型支持的“先行关系”如下:

<1>、程序次序规则(Program Order Rule):在一个线程内,按照程序代码(控制流)顺序,书写在前面的操作先行发生于书写在后面的操作。

<2>、管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面(时刻)对同一个锁的lock操作。

<3>、volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面(时刻)对该变量的读操作。

<4>、线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。

<5>、线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。

<6>、线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。

<7>、对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。

<8>、传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

      下面示例,演示了如何通过“先行发生”原则来判断操作是否是线程安全的:

private int value = 0;
	
	public void setValue(int value) {
		this.value = value;
	}
	
	public int getValue() {
		return value;
	}

        假设,存在线程A和线程B,线程A先(时间上的先后)调用“setValue(1)”,然后线程B调用了同一个对象的“getValue()”,那么线程B收到的返回值是什么?

        分析:我们可以将上述代码与先行发生原则中的各项规则一一对照,由于两个方法分别由线程A和B调用,不在一个线程中,所以程序次序规则不适用;由于没有同步块,不会发生lock和unlock操作,所以管程锁定规则不适用;由于value变量没有被volatile关键字修饰,所以volatile变量规则不适用;后面的线程启动、终止、中断规则和对象终结规则都不适用;由于没有一个适用的规则,所以传递性规则也不适用。所以结论是:上述代码不是线程安全的,线程B收到的返回值可能是0,也可能是1.

       修复上述问题,有几种方法:将getter/setter方法都定义为synchronized方法,这样就适用于管程锁定规则;或者将value定义为volatile变量,这样就适用于volatile变量规则。

       特别注意:衡量并发安全问题的时候,一切必须以先行发生原则为准,不要受到时间顺序的干扰,因为时间上的先后顺序与先行发生原则之间基本没有太大的关系。

三、Java与线程

1、线程的实现

        实现线程的方式主要有三种:使用内核线程实现、使用用户线程实现、使用用户线程加轻量级进程混合实现。

<1>、使用内核线程实现

       内核线程(Kernel Thread, KLT)是直接由操作系统内核(Kernel)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。

       程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程(Light Weight Process, LWP),轻量级进程就是我们通常意义上所讲的线程。每个轻量级进程都由一个内核线程支持。

       这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型,如下图所示:

《《深入理解Java虚拟机》读书笔记7》 

        这种实现的问题是:轻量级进程具有自己的局限性,系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换,另外轻量级进程要消耗一定的内核资源,因此一个系统支持轻量级进程的数量也是有限的。

<2>、使用用户线程实现

        广义的用户线程(User Thread, UT)是指不是内核线程的所有线程,包括轻量级进程。狭义的定义是指:完全建立在用户空间的线程库上,系统内核不能感知到线程存在的实现,用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。

       这种进程与用户线程之间1:N的关系称为一对多的线程模型,如下图所示:

《《深入理解Java虚拟机》读书笔记7》

      这种实现方式的优点是:不需要系统内核支援,可以拥有更大规模的线程数量,缺点是没有系统内核支援,操作系统只把处理器资源分配到进程,所有的线程操作都需要用户程序自己处理。

<3>、混合实现

      混合实现是指将内核线程与用户线程一起使用的实现方式,用户线程仍然完全建立在用户空间下,因此可以发挥用户线程的大规模并发优势,同时轻量级进程则作为用户线程和内核线程之间的桥梁,来使用内核提供的线程调度功能和处理器映射,并且将用户线程的系统调用通过轻量级进程来完成,也大大降低了进程被阻塞的风险。

      这种用户线程与轻量级进程的数量比是不定的,是M:N的关系,如下图所示:

《《深入理解Java虚拟机》读书笔记7》

注:

       对于Sun JDK来说,它的Windows版与Linux版都是使用一对一的线程模型来实现的,一条Java线程就映射到一条轻量级进程之中,因为Windows和Linux系统提供的线程模型就是一对一的。

       对于Solaris平台,由于操作系统的线程特性可以同时支持一对一(通过Bound Threads或Alternate Libthread实现)及多对多(通过LWP/ Thread Based Synchronization实现)的线程模型。因此Solaris版的JDK中有对应两个平台的专有虚拟机参数:-XX:+UseLWPSynchronization(默认值)和-XX:+UseBoundThreads来明确指定虚拟机使用哪种线程模型。

2、Java线程调度

      线程调度是系统为线程分配处理器使用权的过程。主要的调度方式有两种:协同式(Cooperative Threads-Scheduling)线程调度和抢占式(Preemptive Threads-Scheduling)线程调度。

      协同式调度的系统,线程的执行时间由线程本身来控制,线程执行结束会主动通知系统切换到另一个线程上去。好处是实现简单、不需要线程同步,缺点是线程执行时间不可控。

      抢占式调度的系统,线程执行时间由系统分配,线程执行时间用完后,系统切换到另一个线程来执行。优点是:线程执行时间可控。

      Java使用的线程调度是抢占式调度。

      此外,Java设置了10个级别的线程优先级。

3、线程状态切换

      Java定义了6种进程状态,任何时间点,一个线程只能有且只有其中一个状态:

      ** 新建(New):创建后尚未启动。

      ** 运行(Runnable):线程正在执行的状态(Running)或者正在等待CPU时间的状态(Ready)。

      ** 无限期等待(Waiting):不能被分配CPU时间,只能等待被其他线程显式地唤醒。以下方法会让线程陷入无限期等待状态:

             -> 没有设置Timeout参数的Object.wait()方法;

             -> 没有设置Timeout参数的Thread.join()方法;

             -> LockSupport.park()方法。

       ** 限期等待(Timed Waitting):不能被分配CPU时间,能够等待被其他线程显式地唤醒,也能够在一定时间后被系统自动唤醒。以下方式会让线程进入限期等待状态:

             -> Thread.sleep()方法;

             -> 设置了Timeout参数的Object.wait()方法;

             -> 设置了Timeout参数的Thread.join()方法;

             -> LockSupport.parkNanos()方法;

             -> LockSupport.parkUntil()方法。

       ** 阻塞(Blocked):程序等待进入同步区域的时候,处于这种状态。“阻塞状态”与“等待状态”的区别是:“阻塞状态”在等待获取到一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生;而“等待状态”则是在等待一段时间,或者唤醒动作的发生。

       ** 结束(Terminated):已终止执行的状态。

      这6中状态的转换过程如下图所示:

《《深入理解Java虚拟机》读书笔记7》

四、线程安全

线程安全的定义

         当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。

1、Java语言中的线程安全

        讨论线程安全,要限定于“多个线程之间存在共享数据访问”这个前提,因为如果一段代码根本不会与其他线程共享数据,那么从线程安全的角度上看,程序是串行执行还是多线程执行对它来说是完全没有区别的。

        Java语言中各种操作共享的数据分为五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立

<1>、不可变

       不可变(Immutable)的对象一定是线程安全的。只要一个不可变的对象被正确地构建出来,那其外部的可见状态永远也不会改变,永远也不会看到它在多个线程中处于状态不一致的状态。

       是对象不可变的方法是:将对象中带有状态的变量都声明为final。

<2>、绝对线程安全

      绝对线程安全是一个严格的定义,指一个类能够满足:“不管运行时环境如何,调用者都不需要任何额外的同步措施”。

      在Java API中标注自己是线程安全的类,大多数都不是绝对线程安全的。

<3>、相对线程安全

       相对线程安全的对象是指对该对象的单独的操作是线程安全的,但是对于一些需要特定顺序的连续调用,需要额外的同步手段来保证正确性。

      Java中大多数的线程安全类都属于这种类型。

<4>、线程兼容

       线程兼容的对象是指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中安全地使用。

<5>、线程对立

       线程对立是指不管调用端是否采取了同步措施,都无法在多线程环境中并发使用代码。

 一个线程对立的例子是Thread类的suspend()和resume()方法。

五、线程安全的实现方法

1、互斥同步

       互斥同步保证共享数据在同一个时刻只被一条线程使用。互斥是实现同步的一种手段,临界区(Critical Section)、互斥量(Mutex)和信号量(Semaphore)都是主要的互斥实现方式。

       在Java里面,最基本的互斥同步手段是synchronized关键字。synchronized关键字经过编译后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果synchronized关键字明确指令了对象参数,那就是这个对象的reference;如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。

       对于代码简单的同步块(如被synchronized修饰的getter()或setter()方法),状态转换消耗的时间可能比用户代码执行的时间还要长。所以synchronized是Java语言中一个重量级(Heavyweight)的操作。

       此外,Java.util.concurrent包中的重入锁(ReentrantLock)也可以实现互斥同步,它有三个高级功能:等待可中断、可实现公平锁、锁可以绑定多个条件。

       互斥同步的主要问题是:进行线程阻塞和唤醒所带来的性能问题,这种同步被称为阻塞同步(Blocking Synchronized),属于一种悲观的并发策略(总是认为只要不去做正确的同步措施(加锁),无论共享数据是否真的会出现竞争,那就肯定会出问题)

2、非阻塞同步

      另一种同步策略是:基于冲突检测的乐观并发策略,过程是:先尝试进行操作,如果没有其他线程竞争共享资源,那么就操作成功,如果发生共享数据竞争,就产生冲突,再采用补偿措施(最常见的补偿措施是不断地重试,直到试成功为止)。这种同步不需要线程挂起,因此也称为非阻塞同步(Non-Blocking Synchronization)。

      注意,非阻塞同步的实现,需要操作和冲突检测这两个步骤具备原子性。

3、无同步方案

      如果一个方法本来就不涉及共享数据,那自然就无须任何同步措施去保证正确性。

      可重入代码(Reentrant Code):所有可重入的代码都是线程安全的,但是并非所有的线程安全的代码都是可重入的。可重入代码的共同特征是:不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。

      线程本地存储(Thread Local Storage):线程本地存储的思想是将共享数据的可见范围限制在同一个线程之内。这样不同线程之间就不存在共享数据竞争的问题了。

在Java中通过java.lang.ThreadLocal类来实现线程本地存储的功能,每一个线程的Thread对象中都有一个ThreadLocalMap对象,这个对象存储了一组以ThreadLocal.threadLocalHashCode为键,以本地线程变量为值的K-V值对,ThreadLocal对象就是当前线程的ThreadLocalMap的访问入口。

六、锁优化

1、自旋锁与自适应自旋

       互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力。如果共享数据的锁定状态持续的时间很短,那么传统互斥同步的并发性能九很差。

       自旋锁技术是让等待锁的线程不用放弃处理器执行时间,而是执行一个忙循环(自旋),来看持有锁的线程是否很快就会释放锁。

       自旋等待不能代替阻塞,首先因为自旋要求物理机有多个处理器,其次因为自旋本身要占用处理器时间,如果锁被占用的时间很短,自旋的效果会非常好,如果锁占用的时间很长,自旋就浪费掉大量的处理器资源。所以自旋等待的时间必须有一个限度,可以使用参数-XX:PreBlockSpin来更改。默认值是10次。

      JDK1.6引入了自适应的自旋锁。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省掉自旋过程,以避免浪费处理器资源。

2、锁消除

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

例如以下源代码:

public String concatString(String s1, String s2, String s3) {
		return s1 + s2 + s3;
	}

在JDK1.5之后版本会转化为StringBuilder对象的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()方法内部,说明sb对象不是线程共享对象,因此这里的同步块是没有意义的,所以通过“锁消除”优化后,代码执行时就忽略掉append()方法内部同步锁。

3、锁粗化

       大部分情况下,我们会推荐奖同步块的作用范围限制得尽量小,这样是为了使得 需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快地拿到锁。

       有些情况下,如果一系列连续的操作都对同一个对象反复加锁和解锁,甚至加锁操作出现在循环体中,那么即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。锁粗化会将这些锁的作用域扩大。来减少频繁地加锁和解锁

       上一段代码也存在这样的问题,通过“锁粗化”优化,会将append()方法内部的互斥锁忽略,仅仅在sb.append(s1)之前,和sb.append(s3)之后,加一次锁就可以了。

4、轻量级锁

       JDK1.6引入了轻量级锁,轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的情况下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

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

5、偏向锁

       偏向锁也是JDK1.6中引入的一项锁优化,目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。偏向锁就是指在无竞争的情况下把整个同步都取消掉。

       偏向锁的“偏”就是“偏心”、“偏袒”的意思,这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。当有另外一个线程去尝试获取这个锁的时候,偏向模式就宣告结束,后续的同步操作就如轻量级锁那样执行。

       偏向锁可以提高带有同步但无竞争的程序的性能,但是并不是总是有效,如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。

      

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

发表评论

电子邮件地址不会被公开。 必填项已用*标注