多线程-Sync

2020-12-05  本文已影响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实现细节

在字节码层面

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值
  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 常见的死锁

5.4 避免死锁

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

6. 线程安全

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

保证线程安全措施

参考

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

上一篇下一篇

猜你喜欢

热点阅读