美文网首页
多线程-Sync

多线程-Sync

作者: 麦大大吃不胖 | 来源:发表于2020-12-05 19:08 被阅读0次

    by shihang.mai

    1. sync的基本用法

    当sync所有的代码时=sync方法

    public class T {
    
        private int count = 10;
        
        public synchronized void m() {
            count--;
            System.out.println(Thread.currentThread().getName() + " count = " + count);
        }
      
      //上下方法等同
        public void m() { 
        synchronized(this){
          count--;
            System.out.println(Thread.currentThread().getName() + " count = " + count);
        }
        }
    
    }
    
    public class T {
    
        private static int count = 10;
        
        public static synchronized  void m() { 
            count--;
            System.out.println(Thread.currentThread().getName() + " count = " + count);
        }
        //上下方法等同
        public static void mm() {
            synchronized(T.class) { 
                count --;
          System.out.println(Thread.currentThread().getName() + " count = " + count);
            }
        }
    
    }
    

    以下代码

    public class Account {
        String name;
        double balance;
        
        public synchronized void set(String name, double balance) {
            this.name = name;
    
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
    
            
            this.balance = balance;
        }
        
        public /*synchronized*/ double getBalance(String name) {
            return this.balance;
        }
        
        
        public static void main(String[] args) {
            Account a = new Account();
            new Thread(()->a.set("zhangsan", 100.0)).start();
            
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            
            System.out.println(a.getBalance("zhangsan"));
            
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            
            System.out.println(a.getBalance("zhangsan"));
        }
    }
    
    1. 不打开getBalance()的synchronized,结果会是0,100
    2. 打开getBalance()的synchronized,结果会是100,100
      以上现象原因:
    3. 不打开getBalance()的synchronized,main线程调用getBalance()可以直接获取balance的值,此时balance的值为0
    4. 当打开getBalance()的synchronized,main线程调用getBalance()时,相当于synchronized(this,当前的account对象),而当前的account对象锁被 线程 持有,故getBalance()必须等待线程完成后,再执行

    当sync加在静态方法,锁的是Class对象。当sync加载实例方法,锁的是对象本身

    2. CAS

    CAS:compare and swap/compare and exchange

    1. 两个线程分别对内存中的N+1
    2. 线程拉取内存的N,计算完后,往内存中写回时,对比一下E是否等于N
    3. E==N?写回:重新拉取计算
    
    CAS

    2.1 CAS的ABA问题

    1. 线程3拉取N=5,E=5
    2. cpu切换到线程1,拉取N+1,然后写回内存,此时内存的N=6
    3. cpu切换到线程2,拉取N-1,然后写回内存,此时内存的N=5
    4. cpu切换回线程3,E=5,计算E+1=6,然后写回内存,E=N,写回
    5. 这里就有问题了,其实N已经经历了线程1和线程2的过程,这就是典型的ABA问题
    
    ABA

    只需加上版本号,每改一次+1,这样就能解决上面的ABA问题

    1. 线程3拉取N=5,E=5,version=0
    2. cpu切换到线程1,拉取N+1,然后写回内存,此时内存的N=6,version=1
    3. cpu切换到线程2,拉取N-1,然后写回内存,此时内存的N=5,version=2
    4. cpu切换回线程3,E=5,计算E+1=6,然后写回内存,E=N,但是version不一样,重新计算
    

    jdk中提供了一个类解决ABA问题的类,AtomicStampedReference,使用如下

    //2个参数,分别是初始值,初始版本
    static AtomicStampedReference<Integer> ai = new AtomicStampedReference<>(4,0);
        public static void main(String[] args) {
            new Thread(() -> {
                //四个参数分别是预估内存值,更新值,预估版本号,初始版本号
                //只有当预估内存值==实际内存值相等并且预估版本号==实际版本号,才会进行修改
                boolean b = ai.compareAndSet(4, 5,0,1);
                System.out.println(Thread.currentThread().getName()+"是否成功将ai的值修改为5:"+b);
                try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}
                b = ai.compareAndSet(5,4,1,2);
                System.out.println(Thread.currentThread().getName()+"是否成功将ai的值修改为4:"+b);
            },"A").start();
            new Thread(() -> {
                try {Thread.sleep(5000);} catch (InterruptedException e) {e.printStackTrace();}
                boolean b = ai.compareAndSet(4, 10,0,1);
                System.out.println(Thread.currentThread().getName()+"是否成功将ai的值修改为10:"+b);
            },"B").start();
    
            while (Thread.activeCount() > 2) {
                Thread.yield();
            }
    
            System.out.println("ai最终的值为:"+ai.getReference());
        }
    

    2.2 CAS的实现细节

    下面用AtomicInteger.incrementAndGet()说明,下面是它的方法调用

    public final int incrementAndGet() {
            return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
    }
    public final int getAndAddInt(Object var1, long var2, int var4) {
            int var5;
            do {
                var5 = this.getIntVolatile(var1, var2);
            } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
    
            return var5;
    }
    
    public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
    

    本人能力有限,下面引用大佬的几张图


    image

    看到上面的native方法继续会调用Atomic::cmpxchg,而对于不同的内核是不同的实现,我们只看linux的实现


    image
    //如果是多核处理器,那么返回1,否则返回0
    int mp = os::is_Mp();
    //如果是多核处理器,那么在汇编指令cmpxchg前加入lock
    LOCK_IF_MP()
    

    下面来一个图说明以上所有的步骤


    CAS的实现细节.png

    3. sync实现细节

    在字节码层面

    • 当sync+在方法上

       //在访问标志中增加了SYNCHRONIZED标识
      Flags: PUBLIC, SYNCHRONIZED
      
    • 当sync+在代码块上

      sync字节码层面
      可以看到出现了1个monitorenter和2个monitorexit,其中前后一个为一堆很好理解,中间的monitorexit是为了异常加的

    3.1 sync锁升级

    jdk早期,sync是重量级锁,申请锁资源要用户态内核态切换,即0x80中断。jdk后期,sync有锁升级

    首先来看markword记录的信息,其中记录了锁信息


    markword锁 锁升级
    1. new

      • 偏向锁未启动,new的时候就是普通的对象
      • 启动偏向锁,new的时候匿名偏向。

      ps:jvm启动时,会有很多线程竞争(对象分配内存),所以默认不打开,过一段时间(默认4秒,可用-xx:biasedLockingStratDelay=0设置)再打开

    2. 普通对象(锁标志位:001)

      • 偏向锁未启动,对对象上锁sync,那么立刻转为轻量级锁,即自旋锁
      • 偏向锁启动了,加锁就会变为偏向锁
    3. 匿名偏向(锁标志位:101)

      概念:没指向偏向那个线程,所以叫匿名偏向.

      当向对象上sync锁,就会变为偏向锁

    4. 偏向锁(锁标志位:101)

      • 那个线程先来,我就偏向它。当我第一个线程执行sync代码块时,先将线程地址记录到被锁对象markword上(偏向锁出现原因:工业实践,大多时间都是同一个线程访问一个sync代码块)
      • 当一个线程竞争锁,CAS替换markword的线程地址,如果成功,那么就重偏向了这个线程。如果失败,那么就会进行偏向锁撤销。撤销撤销过程非常麻烦,它要求持有偏向锁的线程到达safe point,再将偏向锁替换成轻量级锁。正因为撤销的代价大,所有做了一个epoch,批量重偏向和批量撤销
      • 多个线程,重度竞争时,直接调用wait(),变为重量级锁

    4.1 epoch解析
    看下面2种场景

    1. 一个线程创建了大量对象并执行了初始的同步操作,之后在另一个线程中将这些对象作为锁进行之后的操作。这种case下,会导致大量的偏向锁撤销操作。

    2. 存在明显多线程竞争的场景下使用偏向锁是不合适的,例如生产者/消费者队列

    批量重偏向解决case1,批量撤销解决case2

    epoch批量重偏向_批量撤销.png
    如上图所示,我们来逐步分析不同颜色的线。
    锁对象所属的类会记录一个epoch值
    • 对于黑色线:当多个线程都对该类的锁对象发生了偏向锁撤销,每撤销一次,epoch= epoch+1。
    • 对于蓝色线: 当类的epoch值超过默认值20时,发生批量重偏向,会将这个值复制到当前线程栈在使用偏向锁的对象上。即:假如当前只有线程A在使用偏向锁对象A,那么就会把类的epoch值复制到锁对象A的epoch
    • 对于绿色线: 当发生下一次批量重偏向,与上一次批量重偏向时间是否超过了默认25s,如果是,类的epoch重置。
    • 对于紫色线: 和绿色线一起看,意思就是在25秒内,类的epoch值超过了默认40,发生批量撤销,那么jvm就认为这个类的锁对象不适合偏向锁,批量撤销这个类的所有偏向锁,变为轻量级锁。并且在之后的加锁,直接加轻量级锁
    1. 轻量级锁,自旋锁(锁标志位:00)

    各个线程在自身的线程栈中生成一个LR(lock record),各个线程竞争锁,竞争到锁的线程就是它的LR记录到被锁对象的markword上,其他线程继续CAS竞争。当重入时,会再多一个LR。实际上LR的生成,只要进入同步块即有,只是偏向锁没用,它记录了当前mark word的快照

    竞争加剧,变为重量级锁,条件:
    - 1.6之前:有线程超过10次自旋,次数可调整,-xx:PreBlockSpin。自旋线程数超过CPU核数的一半
    - 1.6后加入了自适应自旋,jvm自己控制

    1. 重量级锁(锁标志位:10)

      markword上记录的是ObjectMonitor的指针(JVM中用C++写的类),它其中有几个核心的属性

    ObjectMonitor() {
        //记录个数,重入锁
        _count        = 0; 
        //当前获取锁的线程
        _owner        = NULL;
        //处于wait状态的线程,会被加入到_WaitSet
        _WaitSet      = NULL; 
        //处于等待锁block状态的线程,会被加入到该列表
        _EntryList    = NULL ; 
    }
    

    在字节码的时候,会通过monitorenter表示sync代码开始,即进入锁竞争,monitorexit表示"释放锁",每次_count的值-1,当变为0时才是真正释放锁

    sync重量级锁加锁过程.png
    monitor依赖操作系统的Mutex Lock实现,操作系统实现线程之间的切换时需要从用户态转换到内核态,所以早期sync才说是重量级锁

    3.1.1 为什么有自旋锁还要重量级锁

    首先自旋要消耗CPU资源的,如果锁的时间长,或者自旋的线程多,CPu会被大量消耗

    ObjectMonitor中有一个waitSet属性,里面存放了所有申请锁而获取不到的线程,线程调度基于系统。有了waitSet,那么就不消耗CPU资源

    3.1.2 偏向锁是否一定比自旋锁效率高

    不一定,在明确知道会有多线程竞争情况下,偏向锁肯定会涉及锁撤销,这时应该直接使用自旋锁。例如:jvm启动时,会有很多线程竞争(对象分配内存),所以默认不打开,过一段时间再打开,默认4秒(-xx:biasedLockingStratDelay)

    3.1.3 偏向锁存在的意义

    1. 偏向锁是为那些历史遗留的Collection类如Vector和Hashtable等类做的优化,它们里面方法都加了sync,
    2. 如果它们用在没有竞争的情况下使用Vector,却需要付出不停的加锁解锁的代价,如果使用偏向锁,这个代价就比较低了
    3. 偏向锁的撤销的代价太夸张,需要进入safepoint,如果真的竞争很激烈的多线程程序,一开始就关掉可能更好
    4. jdk15已经开始逐步删除

    3.2 可重入锁

    当一个线程获取某个对象锁时候,其他线程会被阻塞,那么当前线程再次获取这个对象锁时是不会被阻塞的,就叫这个锁为可重入锁

    public class T {
        synchronized void m() {
            System.out.println("m start");
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("m end");
        }
        
        public static void main(String[] args) {
            new TT().m();
        }
        
    }
    
    class TT extends T {
        @Override
        synchronized void m() {
            System.out.println("child m start");
            super.m();
            System.out.println("child m end");
        }
    }
    
    1. 程序出现异常,锁默认会被释放
    2. 重入次数必须记录,因为要解锁对应的次数
      • 对于偏向锁,轻量级锁:每重入一次,线程栈上的LR就多一个,第一个LR会记录有一个叫displayed Header,这个displayed Header它记录着上一次状态的markword,hashcode存在这。对于重量级锁, hashcode记录在objectMonitor某个字段上
      • 对于重量级锁,每重入一次,Monitor中的一个属性,即计算器就会+1

    4. sync与lock区别及使用场景

    对比项 synchronized Lock
    锁升级 synchronized涉及锁升级 没锁升级过程
    锁释放 synchronized是JVM关键字,并且在异常时会主动释放锁 Lock要手动上锁释放锁,并且在发生异常时,不会自动释放锁。因此一般来说,使用Lock必须在try{}catch{}块中进行,并且将释放锁的操作放在finally块中进行,以保证锁一定被被释放,防止死锁的发生
    锁中断 synchronized当一个线程处于等待某个锁的状态,是无法被中断的,只有一直等待下去 Lock使用tryLock进行尝试锁定,不管锁定与否,方法都将继续执行,也可以指定tryLock的时间,由于tryLock(time)抛出异常,所以要注意unclock的处理,必须放到finally中,当两个线程同时通过lock.lockInterruptibly()想获取某个锁时,假若此时线程A获取到了锁,而线程B只有在等待,那么对线程B调用threadB.interrupt()方法能够中断线程B的等待过程
    锁类型 synchronized是非公平锁。只能是独占锁 Lock可以是公平锁,也可以是非公平锁。可以是独占锁,可以是共享锁

    使用场景:

    1. 有规定时间或者根据可否获取锁的场景,可用Lock
    2. 可中断,可取消操作,用Lock
    3. 公平队列场景,用Lock,因为synchronized是非公平锁
    4. synchronized是独占锁,Lock有接口是共享锁
    5. 轻度中度竞争用Lock,重度竞争用sync
    6. 读多写少,用自旋锁Lock;写多读少,用重量级锁sync

    5. 死锁

    5.1 死锁定义

    两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去;

    5.2 死锁产生的条件

    死锁出现条件.png
    1. 互斥条件: 强调的是只有一个线程能够获取资源。该资源同时只由一个线程占用,如果还有其他线程请求该资源,只能等待,直至占用的线程释放该资源
    2. 不可剥夺: 强调的是线程使用资源期间不会被抢占。当前线程的资源在使用完之前不能被其他线程抢占,只有等自己使用完才由自己释放
    3. 请求并持有: 强调的是单看一个线程的行为。一个线程已经持有了至少一个资源,但又提出新资源请求,而新资源已被其他线程占有,那当前线程必阻塞,并不释放当前自己已获取的资源
    4. 环路等待: 强调的是从整体来看。发生死锁时,必然存在一个线程一资源的环形链

    5.3 常见的死锁

    • 锁顺序导致死锁

      A线程锁住left,尝试锁住right

      B线程锁住right,尝试锁住left

      结果永久等待

      固定顺序获得锁即可解决

    • 动态的顺序导致死锁

      public void transferMoney(Account fromAccout,Account toAcount,DollarAoumt amout){
        synchronized(fromAccout){
          synchronized(toAcount){
            .....
          }
        }
      }
      

      当账户A转给账户B,B账户又同时转给A账户,就会产生死锁

      用System.identifyHashCode去解决,先分别获取两个account的identifyHashCode,按照identifyHashCode的大小,按顺序获取锁即可。将由外面决定的顺序转为内部决定

    • 协作对象之间,都在方法上加上sync关键字,相互调用导致死锁

      用开放调用解决,即sync代码块而不是sync方法

    5.4 避免死锁

    1. 造成死锁的原因和申请资源的顺序有很大关系,使用资源申请的有序性原则可以避免死锁
    2. 使用Lock.tryLock(Time)

    6. 线程安全

    线程安全问题是指,当多个线程同时读写一个共享资源并且没有任何同步措施时,导致出现脏数据或者其他不可预见的结果

    保证线程安全措施

    • synchronized
    • cas
    • threadLocal
    • final
    • 原子类

    参考

    《并发编程之美》
    https://zhuanlan.zhihu.com/p/34556594
    https://zhuanlan.zhihu.com/p/290991898
    https://my.oschina.net/lscherish/blog/3117851

    相关文章

      网友评论

          本文标题:多线程-Sync

          本文链接:https://www.haomeiwen.com/subject/bvhziktx.html