这次彻底弄懂Synchronized到底是个什么玩意

概述

本文旨在讲述synchronized的本质,用法,及相关的实践。目的在于让读者能够正经理解synchronized,能在工作中达到随心所欲的实践,而不是似懂非懂的套用。照搬知识永远不是学习的目的,形成自我的意识才是进步的关键。废话不多说,接下来我们开始吧。。。(这句好像就是废话-。-)

什么是Synchronized

synchronized是Java的关键字,是一种同步锁,相关的同步锁还有reentranlock,这是java5之后引入的新的同步锁。某种场景下由于reentranlock具有的特性可以替代synchronized而获得更高的性能。他们的区别和联系未来有机会在总结,但值得一提的是reentrantlock可以设置锁的公平性fairness,即优先将锁给等待时间最长的线程,即哪个线程先获取先得到锁,有点排队打饭的意思。相对于lock,对于初学者不建议直接就去使用lock,因为有很多人可能会忘记在finally{}中去释放锁资源而造成不必要的麻烦,相比synchronized就比较友好,它是由JVM管理锁的释放。java中还存在其他锁,什么互斥锁、排它锁、自旋锁1、自适应自旋锁2、死锁、活锁等等,细分的话可以罗列出20种左右的锁,不过你不用担心,他们只不过是根据分类拥有了不同的名字,这里不做赘述。回归正题,看下sychronized的修饰对象。

修饰类别 描述 作用范围 作用对象
代码块 同步代码块 大括号括起来的代码 调用这个代码块的对象
方法 同步方法 整个方法 调用这个方法的对象
静态方法 同上 同上 这个类的所有对象
同步类 整个类 这个类的所有对象

很显然,无论synchronized作用在方法还是对象上,如果他作用的对象是静态的,那么它取得的锁是对类,该类所有的对象一把锁。若它作用的对象是非静态的,那么它取得的锁是对象。

Synchronized实现原理

使用Javap -v XXXXX.class 查看jvm编译后的机器指令,我们发现Synchronized代码处生成了monitorenter/monitorexit指令,分析之后可以看出,使用Synchronized进行同步,其关键就是必须要对对象的监视器monitor进行获取,当线程获取monitor后才能继续往下执行,否则就只能等待。而这个获取的过程是互斥的,即同一时刻只有一个线程能够获取到monitor。由于Synchronized先天具有重入性。线程不需要再次获取同一把锁。每个对象拥有一个计数器,当线程获取该对象锁后,计数器就会加一,释放锁后就会将计数器减一。
任意线程对Object的访问,首先要获得Object的监视器,如果获取失败,该线程就进入同步状态,线程状态变为BLOCKED,当Object的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。

Synchronized的四种状态

无锁、偏向锁、轻量级锁、重量级锁
偏向锁:减少同一线程获取锁的代价。大多数情况下,锁不存在多线程竞争,总是由同一个线程多次获得。核心思想:如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word的结构也变为偏向锁结构,当该线程再次请求锁时,无需在做任何同步操作,即获取锁的过程只需要检查Mark Word的ThreadID即可,这样就省去了大量有关锁申请的操作。显然,偏向锁不适用于锁竞争比较激烈的多线程场合。
轻量级锁: 由偏向锁升级而来,偏向锁运行在一个线程进入同步块的情况写下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁。
重量级锁: 若存在同一时间访问同一锁的情况,就会导致轻量级锁膨胀为重量级锁。

怎么用Synchronized

1.同步代码块

(1) this指当前类对象

  synchronized(this){
    .....
  }

(2) 作用于配置的实例对象

 public Object obj = new Object(); 
 synchronized(obj){
   .....
 }
 
 String lock =" ";
 synchronized(lock){
   .....
 }

(3)类对象

  synchronized(TestClass.class){
   .....
 }
2. 同步方法

普通方法

 public synchronzied void method(){
      .....
 }

静态方法

  public static synchronzied void method(){
       .....
 }

问题引入

接下来贴一个别人博客的例子分析一下使用synchronized的误区。
根据他的源码我整理重写并加了注释了之后大致如下:主要是一个模拟多线程发票的场景。

第一种方式,实现了Runnable接口,这里synchronized(this)换成

String lock ="";
synchronized(lock){
}

效果是一样的,所以不要总是纠结到底锁当前类还是配置对象,作用范围不同而已,实际分析还是看场景。

/**
 *  sychronized(this)   同步类对象
 */
public class SaleTicket implements Runnable{

    public int total;
    public int count;

    public SaleTicket() {
        total = 100;
        count = 0;
    }

    @Override
    public void run() {

        while (total>0){                //想一下这里的意义
            synchronized (this){        //不加会造成多卖不存在的票
                if(total>0){            //多线程常用的double-check方式
                    try {
                        Thread.sleep(new Random().nextInt(1000));   //sleep()方法会让运行状态线程进入blocked状态,让其他Runnable线程进入Running,从而模拟并发。
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    total--;
                    count++;
                    System.out.println(Thread.currentThread().getName()+"\t当前票号:"+count);
                }
            }
        }
    }

    public static void main(String[] args) {
//        方式一
        SaleTicket st = new SaleTicket();
        for(int i=0;i<=5;i++){
            new Thread(st,"售票点"+i).start();
        }
    }
}

第二种方式,继承了Thread类,synchronized (obj){…},同步锁作用在配置对象obj上。

/**
 * synchronized(obj)  同步对象
 */
class SaleTicketTo extends Thread{

    public static int ticket =100;
    public Object obj = new Object();

    @Override
    public void run() {
        while (true){
            synchronized (obj){
                if(ticket>0){
                    System.out.printf("%s正在出票,票号:%d\n",Thread.currentThread().getName(),ticket);
                    --ticket;      //温习一下 --i 和 i--,但在这里没有什么区别
                }else{
                    break;
                }
            }
        }
    }

    public static void main(String[] args) {
        SaleTicketTo t1 = new SaleTicketTo();
        SaleTicketTo t2 = new SaleTicketTo();
        t1.setName("窗口1");      //给线程起名字是一个好习惯,利于问题的排除
        t2.setName("窗口2");
        t1.start();t2.start();
    }
}

上面两种方式你觉得哪种实现了场景的需要呢,答案是第一种,我贴一下第二种的结果

这次彻底弄懂Synchronized到底是个什么玩意
显示两个窗口1、2都出售了一张100编号的票,出现这种问题的原因是什么?
大家都知道类的静态变量在内存中只有一个,java虚拟机在加载类的过程中为静态变量分配内存,静态变量位于方法区,被类的所有实例共享。所以共享资源就是ticket没毛病。但是因为t1,t2都是使用new SaleTicketTo()创建了两个线程,生成了两个实例obj,而run方法内同步代码块锁的是obj,现在线程都有了obj对象锁,不必等待。解决方案,把obj对象修改成静态让两个线程公用一把对象锁,就OK了。或者我们这里不去获取对象锁,而是去获取类锁,即sychronized(SaleTicketTo.class),也能达到同步的效果。简言之,锁住了不同的锁一点意义没有,只有我们大家都作用于同一对象,谁有当前对象锁谁执行才有意义。

synchronized的注意项

synchronized修饰的方法在子类覆盖实现时,不能被继承方法的同步性,需要在子类覆盖方法上显示声明。当然,子类可以直接调用父类的同步方法。

题外话

加锁造成的性能损失是非常大的,如果没有并发性问题,不要在你的程序加锁,即使使用同步锁,也尽可能的把锁的粒度缩小,即缩小同步代码的范围,例如ConcurrentHashMap就采用的segment分段锁技术,保证局部的线程同步。上面我们提到多线程协作是通过锁的获取和释放决定谁来执行,这个过程就会涉及锁的频繁获取和释放这两个动作,那么对于多线程锁的优化可以从降低锁的获取和释放时间这个思路去寻求途径。


  1. 自旋锁,hotspot团队研究发现,共享资源被锁定的时间是很短的,即当共享资源锁定时间在自旋时间之内,就没必要挂起线程,可让cpu分点时间片去让线程进行忙循环。这里就是比较挂起线程的开销和牺牲一点cpu让线程自旋消耗的性能,作出取舍。 ↩︎

  2. 自适应自旋锁,因为我们无法评估自旋的次数,即到底等待线程要忙循环几次,java6引入类更加聪明的自适应自旋锁,它依据上一次在同一锁上的自旋时间及锁的拥有者的状态来自适应自旋次数。 ↩︎