JVM-高效并发

Java内存模型与线程:

  Java内存模型的目的是定义程序中各个变量的访问规则,此处的变量包括实例字段、静态字段和构成数组对象的元素,但不包括局部变量和方法参数,因为后者是线程私有的。

  Java内存模型规定所有的变量都存储在主内存中,每个线程有自己的工作线程,线程的工作内存中保存了被该线程用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写到主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。


内存间交互操作:

  关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存的实现

  Java内存模型中定义了8种操作来完成,虚拟机实现时必须保证每一种操作都是原子的、不可再分的。

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

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

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

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

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

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

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

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

  Java内存模型要求read和load、store和write操作必须按顺序执行,但不保证是连续执行。此外还规定在执行上必须满足如下规则:

    1、不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取但工作内存不接受,或从工作内存发起回写但主内存不接受的情况

    2、不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了必须把变化同步回主内存

    3、不允许一个线程无原因地(没发生过任何的assign操作)把数据从线程的工作空间同步回主内存中

    4、一个新的变量只能在主内存诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即对一个变量执行user、store操作之前,必须先执行过assign和load操作

    5、一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可被同一条线程重复执行多次。多次执行lock后,只有执行相同次数的unlock才会被解锁

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

    7、如果一个变量事先没有被lock锁定,那就不允许对它执行unlock操作,也不允许去unlock一个被其他线程锁定住的变量

    8、对一个变量执行unlock之前,必须先把此变量同步回主内存(执行store、write操作)


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

  Java内存模型要求内存交互的8种操作都具有原子性,但是对于64位的数据类型(long和double)有一条相对宽松的规定:

  允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行。即允许虚拟机实现选择可以不保证64位数据类型的load、store、read、write这四个操作的原子性。这就是long和double的非原子性协定。


原子性:

  由Java内存模型直接保证的原子性变量操作包括read、load、assign、use、store和write。我们可以认为基本类型数据的访问读写是具备原子性的(例外就是long和double的非原子性协议)。

  此外还提供了lock和unlock来满足更大范围的原子性保证。虚拟机并为直接将操作开发给用户使用,但提供了字节码指令monitorenter和monitorexit来隐式的使用这两个操作。这两个字节码指令反映到java代码中的synchronized关键字,因此synchronized块之间的操作也具备原子性

可见性:

  当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。通过volatile可以保证可见性外,还可以通过synchronized和final。同步快的可见性是对一个变量释放锁之前,必须先把此变量同步回主内存。而final关键字的可见性是指,被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把this引用传递出去,那在其他线程中就能看到final字段的值,保证可见性

有序性:

  Java提供volatile和synchronized两个关键字来保证线程之间操作的有序性。volatile通过进制指令重排序,而synchronized通过一个变量同一时刻只允许一个线程对其进行lock操作来保证有序性。持有同一个锁的两个同步块只能串行地进入


线程安全的实现方法:

  1、互斥同步

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

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

  除了synchronized之外,还可以使用java.util.concurrent包中的重入锁(ReentranLock)来实现同步。

  2、非阻塞同步

  互斥同步最主要的问题就是进行线程阻塞和唤醒带来的性能问题,因此这种同步也称为阻塞同步。互斥同步属于一种悲观的并发策略,而非阻塞同步是一种基于冲突检测的乐观并发策略。即先进行操作,如果没有其他线程争用共享数据,那操作就成功,如果共享数据有争用产生了冲突,那就再采取其他补偿措施。实现方式可以有版本号机制或CAS算法

  3、无同步方案

  同步只是保证共享数据争用时的正确性的手段,如果一个方法本身不涉及共享数据,那它自然就无须任何同步措施。有一些代码天生就是线程安全的。如:可重入代码、线程本地存储(java.lang.ThreadLocal)


锁优化:

  1、自旋锁与自适应自旋

    自旋锁:互斥同步时,共享数据的锁定状态只会持续很短一段时间,为了这段时间去挂起和恢复线程并不值得。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名。

    自旋锁在JDK6中就默认开启了,可通过参数--XX:+UseSpinning来设置。自旋等待不能完全替代阻塞,因为它还是要占用处理器时间。如果锁被占用的时间短,那么效果当然就很好了!反之,相反!自旋等待的时间必须要有限度。如果自旋超过了限定次数任然没有获得锁,就应该挂起线程。自旋次数的默认值是10次,用户可以修改--XX:PreBlockSpin来更改。

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

  2、锁消除

   指的就是虚拟机即使编译器在运行时,如果检测到那些共享数据不可能存在竞争,那么就执行锁消除。锁消除可以节省毫无意义的请求锁的时间。锁消除的主要判断依据来源于逃逸分析的数据支持,如果判断一段代码中,堆上所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据来对待,认为是线程私有的,同步加锁就无须进行。

  3、锁粗化  

    原则上,我们再编写代码的时候,总是推荐将同步快的作用范围限制得尽量小——只在共享数据的实际作用域才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待线程也能尽快拿到锁。大部分情况下,上面的原则都是没有问题的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,那么会带来很多不必要的性能消耗。

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();
}

  这段代码,每个append()方法中都有一个同步块,锁是sb对象。连续的append()方法会对同一个对象反复加锁解锁,虚拟机探测到这样一串零碎的操作都对同一对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部。即扩展到第一个append()操作之前直至最后一个append()操作之后,这样只需要加锁一次就可以了。

  4、轻量级锁

    轻量级锁不是为了代替重量级锁,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。了解轻量级锁的原理之前需要了解什么是MARKit Word。

    Mark Word:HotSpot虚拟机的对象头分为两部分信息,其中一部分用于存储对象自身的运行时数据(如哈希吗、GC分段信息等),官方称为Mark Word。32位的HotSpot虚拟机中对象未被锁定状态下的对象存储分布

    JVM-高效并发

    轻量级锁加锁原理:

      代码进入同步块时,如果此同步对象没有被锁定(锁标识位为01状态),虚拟机首先在当前线程的栈桢中建立一个锁记录空间(Lock Record)用于存储对象目前的Mark Word的拷贝(Displaced Mark Word)。然后虚拟机使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,如果更新成功,则线程拥有了该对象锁,并将Mark Word的锁标志位转变为00,即表示此对象处于轻量级锁定状态。如果更新失败,虚拟机会检测对象的Mark Word是否指向当前线程的栈桢,如果是则说明当前线程已拥有这个对象的锁,否则说明这个锁对象被其他线程抢占。如果有两个以上线程争用同一个锁,那轻量级锁就膨胀为重量级锁,锁标志位变为10.Mark Word存储的就是指向重量级锁(互斥锁)的指针,后面等待锁的线程也要进入阻塞状态。

    解锁:

      通过CAS操作,如果对象的Mark Word仍然指向线程的锁记录,就把对象当前的Mark Word和线程中复制的Displaced Mark Word复制回来。替换成功则同步过程完成,替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。

      在没有竞争的情况下,轻量级锁使用CAS操作避免了使用互斥量的开销。但如果存在竞争,除了互斥量的开销外,还额外发生CAS操作,轻量级锁会比传统的重量级锁更慢。

  5、偏向锁

    目的是消除数据在无竞争情况下的同步,与轻量级锁相比,其在无竞争情况下把整个同步都消除掉,连CAS操作都不做。通过参数-XX:+UseBiasedLocking来设置。偏向锁的“偏”就是偏心的偏,它的意思是会偏向于第一个获得它的线程,如果在接下来的执行中,该锁没有被其他线程获取,那么持有偏向锁的线程就不需要进行同步

    原理:

      当虚拟机启用了偏向锁,当锁对象第一次被线程获取时,虚拟机将对象头中的标志位设为01,即偏向模式。同时使用CAS把获取到这个锁的线程的ID记录在对象的Mark Word之中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。当另一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定状态,撤销偏向后恢复到未锁定或轻量级锁定的状态。后续同步操作就如轻量级锁那样执行。

    对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。

    

上一篇:Java网络编程Demo,使用TCP 实现简单群聊功能Groupchat,创建一个服务端,使多个客户端都能收到消息


下一篇:经验总结之Android framework开发