聊聊synchronized关键字


接触java并发编程,用得最早最多的大概就是这个synchronized关键字了,synchronized是基于jvm实现的,不同于juc包里的锁的实现。发现想要讲好synchronized并不容易,因为synchronized是jvm的东西,想要讲清楚就要涉及jvm,涉及jvm就要涉及源码,涉及源码就要回到C++,汇编。网上大部分文章都停留在jvm这一层,少数涉及到源码,我看源码也花了不少时间。这里就纯当聊天,慢慢展开了(后记:比我想象的内容要多,写的太累了,剩下的慢慢更新了)。

1、synchronized基本用法

synchronized关键字可用于修饰方法和代码块。我们举例说明:
假设我开了一家宠物店,客人可以到店里来撸猫,店子比较小每日只提供一只猫撸,猫每次只能供一位客人撸。
从上面的描述中,我们知道猫就是临界资源,需要互斥访问,所有对猫的操作都需要进行同步,加上synchronized关键字。

/**
 * Created by gameloft9 on 2019/4/28.
 */
@Slf4j
public class LuMaoService {

    private static String catName;

    /**
     * 同步方法的情况
     */
    public synchronized void luMao_A(String player) {
        try {
            // 一些准备工作
            log.info("{}进店了",player);
            log.info("{}准备洗手",player);
            Thread.sleep(3000);
            log.info("{}洗手完毕",player);

            log.info("{}开始撸{}猫",player,catName);
            Thread.sleep(2000);
        } catch (InterruptedException e) {
        }

        log.info("{}猫撸完了,离开了本店",player);
    }

    /**
     * 静态同步方法的情况
     */
    public static synchronized void getOneCat(String newCatName) {
        log.info("今日提供{}猫", newCatName);
        catName = newCatName;
    }

    /**
     * 同步块的情况
     */
    public void luMao_B(String player) {
        // 一些准备工作
        try{
            log.info("{}进店了",player);
            log.info("{}准备洗手",player);
            Thread.sleep(3000);
            log.info("{}洗手完毕",player);
        }catch(InterruptedException e){
        }

        synchronized (this) {
            try {
                log.info("{}开始撸{}猫",player,catName);
                Thread.sleep(2000);
            } catch (InterruptedException e) {
            }

            log.info("{}猫撸完了,离开了本店",player);
        }
    }
}

然后我们来测试下,现在同时有三个线程需要访问临界资源:

/**
 * 模拟多线程访问临界资源
 * Created by gameloft9 on 2019/4/28.
 */
public class TestLuMao implements Runnable {

    private LuMaoService luMaoService;

    private String name;

    public TestLuMao(String name,LuMaoService luMaoService){
        this.name = name;
        this.luMaoService = luMaoService;
    }

    public void run() {
        luMaoService.luMao_A(name);
    }
    
    public static void main(String[] args) {
        LuMaoService luMaoService = new LuMaoService();
        LuMaoService.getOneCat("加菲猫");

        new Thread(new TestLuMao("寡姐",luMaoService)).start();
        new Thread(new TestLuMao("美国队长",luMaoService)).start();
        new Thread(new TestLuMao("钢铁侠",luMaoService)).start();
    }
}

在这里插入图片描述
从结果中,我们可以看到,确实是互斥的访问了临界资源,并没有发生互相干扰的问题。当synchronized修饰实例方法的时候,这个方法每次就只能一个线程执行。当静态资源需要互斥访问的时候,synchronized就用于修饰相应的静态方法,例如这里是获取猫。使用同步块也可以达到同步实例方法的目的,例如这里的luMao_B方法。与同步方法不同的是,同步块缩小了同步的范围,尽可能提高程序的并发程度。以撸猫为例,进店洗手这一步操作其实是没有必要进行同步的,别人在撸猫的时候,我可以先去洗手,等别人撸完了我就可以直接撸了。我们在测试代码里,用luMao_B代替luMao_A重新运行代码,结果如下:
在这里插入图片描述
对比两者的结果,同步块的效率是要大于同步方法的,在方法中包含一些不要同步但是又非常耗时的操作时,尤其明显。所以工作中,建议尽量使用同步块。

synchronized是需要对某个对象上锁的,在同步实例方法中,上锁的对象是实例对象。在静态同步方法中,上锁的是类对象,即LuMaoService.class。同步块中,上锁的对象必须显示给出,例如示例代码中的this代表实例对象自己。弄清楚了锁对象,才能避免一些稀奇古怪的错误。还是上面的例子,改写一下测试代码,每个线程自己new一个LuMaoService,然后运行。

/**
 * 模拟多线程访问临界资源
 * Created by gameloft9 on 2019/4/28.
 */
public class TestLuMao implements Runnable {

    private LuMaoService luMaoService;

    private String name;

    public TestLuMao(String name,LuMaoService luMaoService){
        this.name = name;
        this.luMaoService = luMaoService;
    }

    public TestLuMao(String name){
        this.name = name;
    }

    public void run() {
        LuMaoService luMaoService = new LuMaoService();
        LuMaoService.getOneCat("加菲猫");

        luMaoService.luMao_A(name);
    }

    public static void main(String[] args) {
        new Thread(new TestLuMao("寡姐")).start();
        new Thread(new TestLuMao("美国队长")).start();
        new Thread(new TestLuMao("钢铁侠")).start();
    }
}

在这里插入图片描述
从运行结果可以看到,他们同时撸起猫来了。问题出在哪里呢?原来,调用的是不同的实例的同步方法,虽然还是同步方法,但是锁却不是同一把,当然起不了作用。在使用同步方法的时候,犯这样的错误还比较少,使用同步块的时候,不小心就很容易犯这样的错误,特别是和wait、notify组合使用的时候。这样的错误例子后面讲wait、notify的时候会给出。

2、synchronized与volatile

volatile也是java的一个关键字,它用于修饰共享变量。Java 语言规范第三版中对 volatile 的定义如下: java 编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。Java 语言提供了 volatile,在某些情况下比锁更加方便。如果一个字段被声明成 volatile,java 线程内存模型确保所有线程看到这个变量的值是一致的。volatile保证变量的更新,对于所有的线程都是立即可见的。

共享变量
在多个线程之间能够被共享的变量被称为共享变量。共享变量包括所有的实例变量,静态变量和数组元素。他们都被存放在堆内存中,Volatile 只作用于共享变量。

如果某个共享变量属于临界资源,一写多读,仅仅使用volatile关键字修饰即可。如果是多写,那么还需要搭配synchronized关键字:

/**
 * Created by gameloft9 on 2019/4/28.
 */
public class Counter {

    private volatile int count = 0;

    public synchronized void increaseByOne(){
        count += 1;
    }

    public int getCount(){
        return count;
    }
}

因为volatile只能保证可见性,并不能保证原子性。volatile要讲透彻,还需要涉及到CPU缓存的MESI协议,内存屏障等概念。本文的主题是synchronized,就不再深入了。对volatile感兴趣的同学可以百度下。

3、synchronized与wait、notify

synchronized不仅仅可以用来实现互斥,当和wait、notify一起使用时还可以实现同步。有时候线程虽然进入了同步块,但是执行的条件并不成熟,需要等待别的线程完成前提任务后才能做。典型的就是生产者消费者问题,产品队列不满的时候,生产者才能生产消费品并放入队列中,否则就需要等待消费者从中取出产品消费。产品队列不空的时候,消费者才能从中取出产品消费,否则就需要等待生产者往产品队列放入产品。还有类似的问题就是两个线程按顺序打印AB,三个线程按顺序打印ABC等等。

wait、notify的标准使用方式是:

 synchronized (lock){
      while(执行条件不满足){
          try{
              lock.wait(500); // 让出锁,让其它线程有机会获取到锁
          }catch(InterruptedException e){
          }
      }

      if(执行条件满足){
          // 执行逻辑
          // 可能需要更改条件变量值
      }

      lock.notifyAll(); //通知其他线程
}

由于虚假唤醒的问题(虚假唤醒问题可以参考之前的文章LockSupport原理),我们wait应该始终放在一个while循环里面,而不是像下面这样:

synchronized (lock){
     if(执行条件不满足){
          try{
              lock.wait);
          }catch(InterruptedException e){
          }
      }

      if(执行条件满足){
          // 执行逻辑
           // 可能需要更改条件变量值
      }

      lock.notifyAll(); //通知其他线程
}

这样的写法由于线程被错误的唤醒了,但条件其实并不满足,最终造成程序异常。
使用wait、notify的时候,一定要注意锁对象的一致性,不然会导致莫名其妙的报错。例如synchronized锁住的是一个对象,而wait、notify却使用了另外一个对象,如下面代码所示:

  synchronized (lock){
      while(执行条件不满足){
          try{
              wait(500); // 让出锁,让其它线程有机会获取到锁
          }catch(InterruptedException e){
          }
      }

      if(执行条件满足){
          // 执行逻辑
          // 可能需要更改条件变量值
      }

      notifyAll(); //通知其他线程
}

这样的代码运行起来会抛出异常:

Exception in thread "Thread-0" java.lang.IllegalMonitorStateException

上面synchronized锁住的是一个lock对象,而wait、notify由于误操作,没有使用lock.wait()、lock.notifyAll()。它锁住的其实是this对象,也就是我们的实例对象。因此写代码的时候,一定要注意synchronized、wait、notify使用的是同一个锁对象,新手很容易犯这样的错误。

另外一个需要注意的是,wait、notify必须在synchronized代码块里才行,否则也会出现IllegalMonitorStateException异常。不这样做的话会有一个“lost-wake-up”的问题,原因是线程的执行是无序的,随时可能存在线程切换,notify可能早于wait执行,导致wait的线程永远不可能被唤醒。具体可以参考这篇文章:阿里面试题,Java中wait()方法为什么要放在同步块中?后面讲synchronized底层实现的时候,会对这个问题做进一步的说明。

根据《Effective JAVA》,由于jdk 1.5以后引入了原子类(例如AtomicInteger)、Executor组件、并发集合(例如ConcurrentHashMap)和同步器(例如CountdownLatch),代码里很少需要自己去写wait、notify了。所以除非真的需要,否则还是用jdk提供的这些高级工具来实现吧,例如ReentrantLock就提供了Condition对象,可以模拟wait和notify操作,如下所示:

/**
 * Created by gameloft9 on 2020/4/21.
 */
public class Client {
    private static Lock lock = new ReentrantLock();
    private static Condition isDone = lock.newCondition(); // 条件对象,通过它进行wati、 notify
    private static volatile int count = 1;

    public static void main(String[] args) throws Exception{
        new Thread(new Runnable() {
            @Override
            public void run() {
                doSomething();
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                afterProcess();
            }
        }).start();

    }


    public static void doSomething() {
        lock.lock();
        try {
            System.out.println("do任务执行");
            Thread.sleep(2000); // 模拟任务执行
            count = 0;

            // 任务完成通知线程
            System.out.println("do任务执行完毕,通知");
            isDone.signal(); // 模式sychronized的notify
        } catch (Exception e) {

        } finally {
            lock.unlock();
        }
    }

    public static void afterProcess() {
        lock.lock();
        try {
            while (count != 0) {
                System.out.println("after等待");
                isDone.await(); // 模拟sychronized wait()
            }

            if (count == 0) {
                System.out.println("after任务执行");
                Thread.sleep(2000); // 模拟任务执行
            }

            System.out.println("after任务执行完毕");
        } catch (Exception e) {
        } finally {
            lock.unlock();
        }
    }
}

在这里插入图片描述
因此现在一般的建议是,能使用无锁编程就不要使用重型synchronized,能用同步块就不要用同步方法,能锁实例就不要锁类。

4、synchronized与interrupt

线程中断用于线程需要中断自己运行的时候,通过调用interrupt()实现:
如果线程是阻塞(Object.wait, Thread.join和Thread.sleep)的,则线程会自动检测中断,并抛出中断异常(InterruptedException),然后将中断信号复位。这也是为什么调用sleep()的时候必须要try包括并catch一下InterrupteException。

try{      
   Thread.sleep(3000); 
}catch(InterruptedException e){
 // 中断信号已经复位,调用Thread.currentThread().isInterrupted() == false;
}

如果线程没有被阻塞,仅仅是向当前线程发送了一个中断信号,线程不会帮助我们检测中断、抛异常和复位的,需要我们手动进行中断检测,在检测到中断后,应用可以做相应的处理。
这和synchronized有什么关系呢?
意思就是在同步块里,如果没有阻塞操作,即使当前线程调用了interrupt()操作,仍然不会中断线程。例如:

/**
 * Created by gameloft9 on 2019/4/28.
 */
@Slf4j
public class Counter {
    private int count = 0;

    public void increase(){
       synchronized (this){
           while(true){
               count += 1;
               log.info("cont = {}",count);
           }
       }
    }
}

我们同步块里就循环的对count进行+1操作,然后开线程调用,然后马上调用interrupt()中断自己。

/**
 * Created by gameloft9 on 2019/4/28.
 */
@Slf4j
public class TestCounter implements Runnable {
    private Counter counter;

    public TestCounter(Counter counter) {
        this.counter = counter;
    }

    public void run() {
        counter.increase();
    }

    public static void main(String[] args) {
        Counter counter = new Counter();

        Thread tmp = new Thread(new TestCounter(counter));
        tmp.start();

        tmp.interrupt(); // 调用中断,没什么作用
    }
}

在这里插入图片描述
结果是线程并没有因为中断而停止,一直不停的打印。
因此在同步块里要检测出中断信号,必须手动检测,例如:

synchronized (this){
           while(true){
               // 手动检测中断
               if(Thread.currentThread().isInterrupted()){
                   // Thread.interrupted(); //根据业务需求确定是否需要清除中断位
                   return;
               }

               count += 1;
               log.info("cont = {}",count);
           }
       }

对于上面的用法和一些条条框框,我们将从底层原理出发再做一遍解释。

5、synchronized底层原理

synchronized原理部分应该是本文最难的部分。网上文章有很多,基本离不开三个东西:monitorenter、monitorexit两个指令,对象头分析,锁分类及升级。monitorenter和monitorexit两个指令比较简单,javap命令看一下字节码就明白了。另外两个讲清楚的不多,要么是对象头分析不完整,要么是锁升级讲解含糊不清,所以这里决定解决这两个问题。

5.1、对象头(markword)

以HotSpot-1.6 jvm为例,synchronized底层用到了锁,这个锁就存在对象头里。对象头是每个类都有的,因为在jvm实现里面,所有类都会继承下面这个oopDesc的类,每个java类天然的就拥有锁机制。

class oopDesc {
  friend class VMStructs;
 private:
  volatile markOop  _mark; // 这个就是对象头,它是一个指针!并不指向实际的地址。
  union _metadata {
    wideKlassOop    _klass;
    narrowOop       _compressed_klass;
  } _metadata;
  ......

我们再来看对象头markOop是什么,下面是对象头markOop的类结构。

class markOopDesc: public oopDesc {
 private:
  // 将this指针转换为无符号整数,后面用它和锁标识位运算得出锁地址。
  uintptr_t value() const { return (uintptr_t) this; }

 public:
  // 对象头里都是枚举和方法,没有字段
  // 对象头分布枚举
  enum { age_bits                 = 4,
         lock_bits                = 2,
         biased_lock_bits         = 1,
         max_hash_bits            = BitsPerWord - age_bits - lock_bits - biased_lock_bits,
         hash_bits                = max_hash_bits > 31 ? 31 : max_hash_bits,
         cms_bits                 = LP64_ONLY(1) NOT_LP64(0),
         epoch_bits               = 2
  };

  // 锁标志位值,请结合下面的图理解
  enum { locked_value             = 0, // 轻量级锁
         unlocked_value           = 1, // 无锁状态
         monitor_value            = 2, // 重量级锁
         marked_value             = 3, // 标记该对象无效,应当被GC
         biased_lock_pattern      = 5 // 偏向锁
  };
 ......
}

对象头逻辑上并不是一个面向对象的类,它实际上是一个用来描述锁相关内容的字(word),因此对象头的指针并不指向某个实际的地址。在32位机器里,这个word就是一个32bit的布局。这样我们就正式的引入对象头的布局(我懒得画了,这里借用网上的图片- -):
在这里插入图片描述
根据对象头分布的枚举,我们可以了解到每个标记位的长度,例如lock_bits=2表示锁标记位占2个bit,biased_lock_bits = 1表示用1bit表示是否启用偏向锁(要和锁标记位组合使用),4bit分代年龄(与java内存管理有关),2bitEpoch(偏向时间戳)。每个标记位存的内容与锁类型是息息相关的,因此对象头的内容的分析会分散在下面锁分类里面。

5.2、偏向锁

有关锁的状态类型和实际存储内容是完全根据对象头的低三位来判断的,例如 0 0 1表示该对象没有被上锁,处于无锁状态。剩余空间存的就是分代年龄和对象的hashCode。1 0 1表示该对象上的是一把偏向锁,它会存下持有这把锁的线程ID,最开始没有线程竞争的时候线程ID是0,此时对象头分布如下:

0|Epoch|分代年龄|1 01

我们知道偏向锁之所以叫偏向锁,是因为它偏心于第一个持有它线程。就好比我们去吃饭,首先要点盖浇饭,然后老板才会去做盖浇饭给你吃。但是如果每次我们都吃盖浇饭,不吃别的,老板一看我进来了,就直接给我做盖浇饭,就省去了点餐的步骤,是不是快多了?

经研究表明,很多同步方法经常是只有一个线程访问的,既然都是同一个线程,那干脆把线程ID存下来好了,下次进入同步方法,发现还是你,那恭喜直接执行吧。使用偏向锁,避免了使用CAS原子操作来上锁解锁,性能得到了提高。

5.3、轻量级锁(待更新)

如果偏向锁遇到了竞争,那么说明偏向锁不适合了,那么它会升级成轻量级的锁。这个时候锁标记位会被改成00,然后剩下的空间存放着指向锁记录的指针。

5.4、重量级锁(待更新)
5.5、整个锁升级流程(待更新)

6、参考文章

1-阿里面试题,Java中wait()方法为什么要放在同步块中?
2-【Java并发编程实战】—–synchronized
3-聊聊并发(一)——深入分析 Volatile 的实现原理
4-聊聊并发(二)——Java SE1.6 中的 Synchronized
5-Thread之八:interrupt中断
6-biased-locking-in-hotspot
7-synchronized原理
8-Java – 偏向锁、轻量级锁、自旋锁、重量级锁
9-JVM同步方法之偏向锁
10-JVM-锁消除+锁粗化 自旋锁、偏向锁、轻量级锁 逃逸分析-30

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值