深入理解Java虚拟机——高效并发

 2019-12-22 10:32  阅读(569)
文章分类:JVM

一Java内存模型与线程

衡量一个服务性能的高低好坏,每秒事务处理数(TPS)是最重要的指标之一。服务端是Java语言最擅长的领域之一,不过写好并发应用程序却是程序开发的难点之一,处理好并发方面的问题通常需要更多的经验。

1. 硬件的效率与一致性

基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但也引入了新的问题,缓存一致性。当多个处理器的运算任务都涉及到同一块内存,各个处理都要遵循一些协议,有MSI,MESI等。为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,只要保证该结果与顺序执行的结果是一致即可。

2.Java内存模型

Java内存模型主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。

不同的平台,内存模型是不一样的,但是jvm的内存模型规范是统一的。其实java的多线程并发问题最终都会反映在java的内存模型上,所谓线程安全无非是要控制多个线程对某个资源的有序访问或修改。总结java的内存模型,要解决两个主要的问题:可见性和有序性。

何谓可见性? 多个线程之间是不能互相传递数据通信的,它们之间的沟通只能通过共享变量来进行。Java内存模型(JMM)规定了jvm有主内存,主内存是多个线程共享的。当new一个对象的时候,也是被分配在主内存中,每个线程都有自己的工作内存,工作内存存储了主存的某些对象的副本,当然线程的工作内存大小是有限制的。当线程操作某个对象时,执行顺序如下:
(1) 从主存复制变量到当前工作内存 (read and load)
(2) 执行代码,改变共享变量值 (use and assign)
(3) 用工作内存数据刷新主存相关内容 (store and write)

JVM规范定义了线程对主存的操作指令:lock,unlock,read,load,use,assign,store,write。当一个共享变量在多个线程的工作内存中都有副本时,如果一个线程修改了这个共享变量,那么其他线程应该能够看到这个被修改后的值,这就是多线程的可见性问题。

**那么,什么是有序性呢 ?**线程在引用变量时不能直接从主内存中引用,如果线程工作内存中没有该变量,则会从主内存中拷贝一个副本到工作内存中,这个过程为read-load,完成后线程会引用该副本。当同一线程再度引用该字段时,有可能重新从主存中获取变量副本(read-load-use),也有可能直接引用原来的副本(use),也就是说 read,load,use顺序可以由JVM实现系统决定。
线程不能直接为主存中中字段赋值,它会将值指定给工作内存中的变量副本(assign),完成后这个变量副本会同步到主存储区(store-write),至于何时同步过去,根据JVM实现系统决定.有该字段,则会从主内存中将该字段赋值到工作内存中,这个过程为read-load,完成后线程会引用该变量副本,当同一线程多次重复对字段赋值时,比如:

java代码:

  1. for(int i=0;i<10;i++)
  2. a++;

线程有可能只对工作内存中的副本进行赋值,只到最后一次赋值后才同步到主存储区,所以assign,store,weite顺序可以由JVM实现系统决定。

synchronized关键字
上面说了,java用synchronized关键字做为多线程并发环境的执行有序性的保证手段之一。当一段代码会修改共享变量,这一段代码成为互斥区或临界区,为了保证共享变量的正确性,synchronized标示了临界区。典型的用法如下:

java代码:

  1. synchronized(锁){
  2. 临界区代码
  3. }

为了保证银行账户的安全,可以操作账户的方法如下:

java代码:

  1. public synchronized void add(int num) {
  2. balance = balance + num;
  3. }
  4. public synchronized void withdraw(int num) {
  5. balance = balance – num;
  6. }

刚才不是说了synchronized的用法是这样的吗:

java代码:

  1. synchronized(锁){
  2. 临界区代码
  3. }

那么对于publicsynchronized void add(int num)这种情况,意味着什么呢?其实这种情况,锁就是这个方法所在的对象。同理,如果方法是public static synchronized void add(int num),那么锁就是这个方法所在的class。
理论上,每个对象都可以做为锁,但一个对象做为锁时,应该被多个线程共享,这样才显得有意义,在并发环境下,一个没有共享的对象作为锁是没有意义的。假如有这样的代码:

java代码:

  1. public class ThreadTest{
  2. public void test(){
  3. Object lock=new Object();
  4. synchronized (lock){
  5. //do something
  6. }
  7. }
  8. }

lock变量作为一个锁存在根本没有意义,因为它根本不是共享对象,每个线程进来都会执行Object lock=new Object();每个线程都有自己的lock,根本不存在锁竞争。
每个锁对象都有两个队列,一个是就绪队列,一个是阻塞队列,就绪队列存储了将要获得锁的线程,阻塞队列存储了被阻塞的线程,当一个被线程被唤醒(notify)后,才会进入到就绪队列,等待cpu的调度。当一开始线程a第一次执行account.add方法时,jvm会检查锁对象account的就绪队列是否已经有线程在等待,如果有则表明account的锁已经被占用了,由于是第一次运行,account的就绪队列为空,所以线程a获得了锁,执行account.add方法。如果恰好在这个时候,线程b要执行account.withdraw方法,因为线程a已经获得了锁还没有释放,所以线程b要进入account的就绪队列,等到得到锁后才可以执行。
一个线程执行临界区代码过程如下:
1 获得同步锁
2 清空工作内存
3 从主存拷贝变量副本到工作内存
4 对这些变量计算
5 将变量从工作内存写回到主存
6 释放锁
可见,synchronized既保证了多线程的并发有序性,又保证了多线程的内存可见性。

生产者/消费者模式
生产者/消费者模式其实是一种很经典的线程同步模型,很多时候,并不是光保证多个线程对某共享资源操作的互斥性就够了,往往多个线程之间都是有协作的。
假设有这样一种情况,有一个桌子,桌子上面有一个盘子,盘子里只能放一颗鸡蛋,A专门往盘子里放鸡蛋,如果盘子里有鸡蛋,则一直等到盘子里没鸡蛋,B专门从盘子里拿鸡蛋,如果盘子里没鸡蛋,则等待直到盘子里有鸡蛋。其实盘子就是一个互斥区,每次往盘子放鸡蛋应该都是互斥的,A的等待其实就是主动放弃锁,B等待时还要提醒A放鸡蛋。
如何让线程主动释放锁。很简单,调用锁的wait()方法就好。wait方法是从Object来的,所以任意对象都有这个方法。看这个代码片段:

java代码:

  1. Object lock=new Object();//声明了一个对象作为锁
  2. synchronized (lock) {
  3. balance = balance – num;
  4. //这里放弃了同步锁,好不容易得到,又放弃了
  5. lock.wait();
  6. }

如果一个线程获得了锁lock,进入了同步块,执行lock.wait(),那么这个线程会进入到lock的阻塞队列。如果调用lock.notify()则会通知阻塞队列的某个线程进入就绪队列。
声明一个盘子,只能放一个鸡蛋

java代码:

  1. package com.jameswxx.synctest;

  2. public class Plate{

  3. List eggs=new ArrayList();

    public synchronized Object getEgg(){

    if(eggs.size()==0){

    try{

    wait();

    }catch(InterruptedException e){

    }

    }

    Object egg=eggs.get(0);

    eggs.clear();//清空盘子

    notify();//唤醒阻塞队列的某线程到就绪队列

    return egg;

    }

    public synchronized void putEgg(Object egg){

    If(eggs.size()>0){

    try{

    wait();

    }catch(InterruptedException e){

    }

    }

    eggs.add(egg);//往盘子里放鸡蛋

    notify();//唤醒阻塞队列的某线程到就绪队列

    }

    }

    声明一个Plate对象为plate,被线程A和线程B共享,A专门放鸡蛋,B专门拿鸡蛋。假设
    1 开始,A调用plate.putEgg方法,此时eggs.size()为0,因此顺利将鸡蛋放到盘子,还执行了notify()方法,唤醒锁的阻塞队列的线程,此时阻塞队列还没有线程。
    2 又有一个A线程对象调用plate.putEgg方法,此时eggs.size()不为0,调用wait()方法,自己进入了锁对象的阻塞队列。
    3 此时,来了一个B线程对象,调用plate.getEgg方法,eggs.size()不为0,顺利的拿到了一个鸡蛋,还执行了notify()方法,唤醒锁的阻塞队列的线程,此时阻塞队列有一个A线程对象,唤醒后,它进入到就绪队列,就绪队列也就它一个,因此马上得到锁,开始往盘子里放鸡蛋,此时盘子是空的,因此放鸡蛋成功。
    4 假设接着来了线程A,就重复2;假设来料线程B,就重复3。
    整个过程都保证了放鸡蛋,拿鸡蛋,放鸡蛋,拿鸡蛋。

    volatile关键字
    volatile是java提供的一种同步手段,只不过它是轻量级的同步,为什么这么说,因为volatile只能保证多线程的内存可见性,不能保证多线程的执行有序性。所以一般人认为的“volatile变量对所有线程是立即可见的,认为基于volatile变量的运算在并发下是安全的”并不正确。而最彻底的同步要保证有序性和可见性,例如synchronized。任何被volatile修饰的变量,都不拷贝副本到工作内存,任何修改都及时写在主存。因此对于Valatile修饰的变量的修改,所有线程马上就能看到,但是volatile不能保证对变量的修改是有序的。什么意思呢?假如有这样的代码:

    java代码:

    public class VolatileTest{

    public volatile int a;

    public void add(int count){

    a=a+count;

    }

    }

    当一个VolatileTest对象被多个线程共享,a的值不一定是正确的,因为a=a+count包含了好几步操作,而此时多个线程的执行是无序的,因为没有任何机制来保证多个线程的执行有序性和原子性。volatile存在的意义是,任何线程对a的修改,都会马上被其他线程读取到,因为直接操作主存,没有线程对工作内存和主存的同步。所以,volatile的使用场景是有限的,在有限的一些情形下可以使用 volatile 变量替代锁。要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件:
    1)对变量的写操作不依赖于当前值。
    2)该变量没有包含在具有其他变量的不变式中
    volatile只保证了可见性,所以Volatile适合直接赋值的场景,如

    java代码:

    public class VolatileTest{

    public volatile int a;

    public void setA(int a){

    this.a=a;

    }

    }

    在没有volatile声明时,多线程环境下,a的最终值不一定是正确的,因为this.a=a;涉及到给a赋值和将a同步回主存的步骤,这个顺序可能被打乱。如果用volatile声明了,读取主存副本到工作内存和同步a到主存的步骤,相当于是一个原子操作。所以简单来说,volatile适合这种场景:一个变量被多个线程共享,线程直接给这个变量赋值。这是一种很简单的同步场景,这时候使用volatile的开销将会非常小。

    此变量对所有线程的可见性。每条线程应用此类型变量前都须要先刷新,履行引擎看不到不一致的景象。运算成果并不依附变量的当前值、或者确保只有单一的线程修改变量的值。禁止指令重排序优化。通俗的变量仅包管在办法履行过程中所有依附赋值成果的处所都能获取到正确的成果。而不克不及包管赋值操纵的次序与法度代码中的次序一致。load必须与use同时呈现;assign和store必须同时呈现。

    原子性、可见性和有序性

    原子性:原子性变量包括read,load,use,assign,store,write这六个以及synchronized块之间的同步块。

    可见性:就是指当一个线程修改了共享变量的值,其他线程能够立即德治这个修改。普通变量与volatile变量的区别是volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。除此之外,是synchronized和final,同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中”这条规则获得的,而final关键字的可见性是指:被final修饰的字段是在构造器中被初始化完成。

    有序性:如果在本线程观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”,后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。Java提供volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的。

    先行发生原则

    Java内存模型中定义的两项操纵之间的偏序关系,若是操纵A先行产生于操纵B,其实就是说在产生操纵B之前,操纵A产生的影响能被操纵B观察到。Java内存模型中“天然性”先行发生关系:

    程序次序规矩:在一个线程内,遵守代码把握流次序,在前面的操纵先行产生于后面的操纵。

    管程锁定规矩:一个unlock操纵先行产生于后面对同一个锁的lock操纵。

    Volatile变量规矩:对一个volatile变量的写操纵先行产生于后面对这个变量的读操纵。

    线程启动规矩:Thread对象的start()办法先行产生于此线程的每个操纵。

    线程终止规矩:线程中的所有操纵都先行产生于对此线程的终止检测。

    线程中断规矩:对线程的interrupt()办法的调用先行产生于被中断线程的代码检测中断事务的产生。

    对象终结过则:一个对象的初始化完成先行产生于它的finalize()办法的开端。

    传递性:若是操纵A先行产生于操纵B,操纵B现象产生于操纵C,那么就可以得出操纵A先行产生于操纵C的结论。

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

    3.Java与线程

    线程实现

    **应用内核线程实现:**内核线程Kernel Thread:直接由操纵体系内核支撑的线程,这种线程由内核类完成线程切换,内核经由过程把持调度器对线程进行调剂,并负责将线程的任务映射到各个处理惩罚器上。局限性:各类过程操纵都须要进行体系调用(体系调用价格相对较高,须要在用户态和内核态中往返切换);轻量级过程要消费必然的内核资料,一次一个体系支撑轻量级过程的数量是有限的。

    **应用用户线程实现:**用户线程:完全建立在用户空间的线程库上,体系内核不能直接感知到线程存在的实现。用户线程的建立、同步、销毁和调剂完全在用户态中完成,不须要内核的帮助。所有的线程操纵都须要用户法度本身处理惩罚。

    3.混合实现:将内核线程和用户线程一起应用的体式格式。操纵体系供给支撑的轻量级过程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的的系统调用要通过轻量级线程的线程来完成,打打降低了进程被阻塞的风险。

    线程调度

    线程调剂是指体系为线程分配处理器使用权过程:协同式、抢占式。

    协同式:线程的执行时间由线程本身把握,线程把本身的工作执行完了之后,要主动通知系统切换到另一个线程上。坏处:线程执行时间不可控制。

    抢占式:每个线程将由体系来分配执行时间,线程的切换不由线程本身来决意。Java应用该种调用体式格式。

    线程优先级:在一些平台上(操纵体系线程优先级比Java线程优先级少)不合的优先级实际会变得雷同;优先级可能会被体系自行改变。

    线程状态转换

    新建NEW:创建后尚未启动的线程处于这种状态。

    运行:Runable包括了操作系统线程状态中的Running和Ready,可能正在执行,也可能等待着CPU为它分配执行时间。

    无限期等待:处于这种状态的线程不会被分配CPU执行时间,它们要等待被其他线程显式地唤醒。【没有设置Timeout参数的Object.wait();没有设置Timeout参数的Thread.wait()。】

    限期等待:处于这种状态也不会被分配CPU执行时间,在一定时间之后它们由系统自动唤醒。【设置Timeout参数的Object.wait();设置Timeout参数的Thread.wait();Thread.sleep()办法。】

    阻塞:在等待获取一个排它锁,这个时间将在另外一个小城放弃这个锁的时候发生;在等待进入同步区域的时候。

    结束:已终止线程的线程状态。

    二.线程安全与锁优化

    **线程安全:**当多个线程接见一个对象时,若不考虑这些线程在运行时环境下的调度和交替执行,也不须要进行额外的同步,或者调用方进行任何其他的协调操纵,调用这个对象的行动都可以获得正确的成果,那这个对象就是线程安全的。线程安全强弱分成五类:

    不可变:只要一个不可变的对象被正确地构建出来。应用final关键字修饰的基本数据类型;若是共享数据是一个对象,那就须要保证对象的行动不会对其状况产生任何影响(String类的对象)。办法:**把对象中带有状况的变量都申明为final,**如Integer类。除String以外还有列举类型、Number的部分子类(AtomicInteger和AtomicLong除外)。

    **绝对线程安全:**不管运行时环境如何,调用者都不需要任何额外的同步措施。大部分Java API都不是绝对的线程安全。

    **相对线程安全:**它需要包成对这个对象单独的操作时线程安全的,对于一些特定的顺序的连续调用,就可能需要在调用端使用额外的同步手段来保证调用的正确性。

    **线程兼容:对象本身并不是线程安全的,**需要经由过程调用规矩确地应用同步手段来保证对象在并发环境中安全地应用。

    **线程对立:**不管调用端是否采取了同步措施,都无法在多线程环境中并发应用的代码。如:Thread类的suspend()和resume()犯法,System.setIn()、System.setOut()、System.runFinalizersOnExit()。

    线程安全的实现方法:

    1.互斥同步:同步是指在多个线程并发接见共享数据时,保证共享数据在同一个时刻只被一条线程使用。互斥体式格式:临界区、互斥量和信号量。Synchronized关键字:编译后会在同步块前后分别形成monitorenter和monitorexit这两个字节码指令。这两个指令都须要一个引用类型的参数来指明要锁定和解锁的对象。若是没有明白指定对象参数,那就按照synchronized润饰的是实例办法还是类办法,去取对应的对象实例或Class对象来作为锁对象

    &p; ReentrantLock相对synchronized的高等功能:等待可中断:当持有锁的线程长久不开释锁时,正在等待的线程可以选择放弃扥带,改为处理其他工作。公平锁:多个线程在守候同一个锁时,必须遵守申请锁的事务次序来一次获取锁;而非公平锁在被开释时,任何一个守候锁的线程都有机会获得锁。Synchronized中的锁非公平锁,ReentrantLock默认也是非公平锁。锁绑定多个前提:一个ReentrantLock对象可以同时绑定多个Condition对象。

    2.非阻塞同步:互斥同主要问题是进行线程阻塞和唤醒所带来的性能问题,是一张悲观的并发策略。基于冲突检测的乐观并发策略,不需要把线程挂起,成为非阻塞同步。

    3.无同步方案:可重入代码,可以在代码执行的任何时刻中断它,转而执行另外一段代码,原来的程序不会出现任何错误,它是线程安全的。线程本地存储,经典Web交互模型中的“一个请求对应一个服务器线程”的处理方式,使得Web服务端的很多应用都可以使用线程本地存储来解决线程安全问题。

    3.锁优化

    自旋锁

    为了让线程等待,让线程执行一个忙循环(自旋)。需要物理机器有一个以上的处理器。自旋等待固然避免了线程切换的开销,带它是要占用处理器时间的,所以若是锁被占用的时候很短,自旋等待的结果就会很是好,反之自旋的线程只会白白消费处理器资源。自适应自旋锁:自旋的时间不再固定,而是由前一次在同一个锁上的自旋时候及锁的拥有者的状况来决意。

    锁清除

    虚拟机即时编译器在运行时,对一些代码上请求同步,然则被检测到不成能存在共享数据竞争的锁进行清除(逃逸解析技巧:在堆上的所稀有据都不会逃逸出去被其它线程接见到,可以把它们当成栈上数据对待)。

    锁粗化

    虚拟机探测到有一串零散的操纵都对同一个对象加锁,将会把加锁同步的局限扩大到全部操纵序列的外部。

    轻量级锁

    轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少系统的重量级锁使用操作系统互斥量产生的性能消耗。轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”。

    偏向锁

    偏向锁是在无竞争情况下把整个同步都消除掉,连CAS操作都不做了。它的锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其它的线程获取,则持有偏向所的线程将永远不需要再进行同步,当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。它是一个带有效益权衡性质的优化,所以并不总是对程序运行有力。

点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> 深入理解Java虚拟机——高效并发

相关推荐