深入理解Java多线程--synchronized的实现原理

版权声明:本文为博主石月的原创文章,转载请注明出处 https://blog.csdn.net/liuxingrong666/article/details/84562861

线程安全是多线程编程中的一个重要的知识点,何为线程安全?在多线程并发中,有很多数据是线程共享的,当我们某个线程去操作共享数据的时候,需要先将共享数据复制到当前线程的内存空间中来,然后进行操作完毕之后再将数据更新到共享空间中去。这就造成了一个问题,当我们有多个线程去读取和操作某个共享数据的时候,会造成数据的读取的不确定性,即我们不能确定读取的是其他线程操作之后还是之前的数据,我们来看看下面的一个例子:

public class CaculateSync {

    //共享数据
    private int i=0;

    private Runnable CaculateRnn=new Runnable() {
        @Override
        public void run() {
            for (int j=0;j<10000;j++){
                //自增
                i++;
            }
        }
    };

    public void test(){
        try {
            Thread thread1=new Thread(CaculateRnn);
            Thread thread2=new Thread(CaculateRnn);

            thread1.start();
            thread2.start();

            thread1.join();
            thread2.join();

            LogUtils.d("i的输出结果="+i);
            i=0;

        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

执行结果:

从执行结果来看,i最后输出结果并不是20000,当然也有可能是20000。为什么会这样的呢?首先i++的操作并不具备原子性,执行分两步进行,首先读取i值,然后执行加1。在这个例子中,有两个线程thread1和thread2同时读取和操作共享数据i,假设thread1读取了数据i,此时i的值为100,在未进行操作和更新数据i的时候线程thread2获得了执行权也读取了i=100的数据,然后thread1和thread2都在i=100的基础上加1去更新数据,更新后的i值为101,虽然两个线程一共进行了两次加1操作,但最后i的却只加了一次,这相当于操作失败了一次。所以说上面的结果因为线程安全的问题,最后的结果就小于20000就不奇怪了。

那么如何解决线程安全问题呢?如何让多线程进行的时候在同一个时刻有且只有一个线程在操作共享数据呢?在java中,关键字synchronized可以解决上面的安全问题,synchronized关键字可以保证在同一时刻,只有一个线程执行一个代码块或者方法。那么我们用synchronized去解决上面例子的问题,来看看:

public class CaculateSync {

    //共享数据
    private int i = 0;

    private synchronized void add() {
        i++;
    }

    private Runnable CaculateRnn = new Runnable() {

        @Override
        public void run() {
            for (int j = 0; j < 10000; j++) {
                //自增
                add();
            }
        }
    };

    public static void test() {
        try {
            Thread thread1 = new Thread(CaculateRnn);
            Thread thread2 = new Thread(CaculateRnn);

            thread1.start();
            thread2.start();

            thread1.join();
            thread2.join();

            LogUtils.d("i的输出结果=" + i);
            i = 0;

        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

输出结果:

从输出结果来看都是20000,说明用了synchronized关键字之后线程是安全的。其实线程同步的关键是哪个线程获得了同步锁,java规定非静态方法的同步锁默认为此方法所在类的实例对象,当某个线程获得了这个对象锁以后就意味着取得了当前方法的执行资格,只有当它执行完毕以后此线程才会释放锁,其他线程才有就会获得锁。当然其他线程还是可以进入没有被synchronized修饰的代码和其他的有synchronized修饰的但锁对象不一样代码。

上面的例子是synchronized修饰非静态方法的使用,synchronized还可以修饰静态方法和代码块。修饰静态方法的时候锁对象是当前类的Class对象,修饰代码块的时候,锁可以是任意对象。

总结一下synchronized三种最主要的应用方式,如下:

1.修饰实例非静态方法,锁对象为当前实例对象,进入同步代码块需要取得对象锁;

2.修饰静态方法,锁对象为当前类的Class对象,进入同步代码块需要取得当前类对象的锁;

3.修饰代码块,需要自己指定锁对象, 进入同步代码块需要取得对应的锁对象;

上面已经介绍了synchronized修饰非静态方法的使用,下面我们来介绍一下synchronized修饰静态方法和代码块的情形:

synchronized修饰静态方法

在讲解synchronized修饰静态方法之前我们先来看看使用synchronized修饰的非静态方法可能出现的问题,来看一下下面代码的执行结果:

public class CaculateSync {

    //共享数据
    public static int i = 0;
    private byte[] lock = new byte[0];

    //修饰非静态方法,锁是当前类的实例对象
    private synchronized void add() {
        i++;
    }

    //修饰静态方法,锁是当前类的class对象
    private synchronized static void addStatic() {
        i++;
    }

    //修饰方法中的代码块
    private void addArea() {
        //锁可以是任意对象
        synchronized (lock) {
            i++;
        }
    }

    private Runnable CaculateRnn = new Runnable() {

        @Override
        public void run() {
            for (int j = 0; j < 1000000; j++) {
                //调用非静态方法
                add();
            }
        }
    };


    public Thread test() {
        
        Thread thread1 = new Thread(CaculateRnn);

        thread1.start();
        return thread1;

    }

    public static void main() {

        try {
            //创建两个对象
            Thread thread = new CaculateSync().test();
            Thread thread1 = new CaculateSync().test();
            thread.join();
            thread1.join();
            LogUtils.d("i的值=" + CaculateSync.i);

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

执行上面的main方法,看结果是什么:

结果小于2000000,为什么呢?其实是因为我们创建了两个CaculateSync对象,add方法的锁对象为两个不同的CaculateSync对象,所以此时方法的锁不一样也就不是安全的了。下面我们改为使用synchronized修饰的静态方法来看看:

public class CaculateSync {

    //共享数据
    public static int i = 0;
    private byte[] lock = new byte[0];

    //修饰非静态方法,锁是当前类的实例对象
    private synchronized void add() {
        i++;
    }

    //修饰静态方法,锁是当前类的class对象
    private synchronized static void addStatic() {
        i++;
    }

    //修饰方法中的代码块
    private void addArea() {
        //锁可以是任意对象
        synchronized (lock) {
            i++;
        }
    }

    private Runnable CaculateRnn = new Runnable() {

        @Override
        public void run() {
            for (int j = 0; j < 1000000; j++) {
                //调用静态方法
                addStatic();
            }
        }
    };


    public Thread test() {
        
        Thread thread1 = new Thread(CaculateRnn);

        thread1.start();
        return thread1;

    }

    public static void main() {

        try {
            //创建两个对象
            Thread thread = new CaculateSync().test();
            Thread thread1 = new CaculateSync().test();
            thread.join();
            thread1.join();
            LogUtils.d("i的值=" + CaculateSync.i);

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

执行结果:

可以看到此时线程是安全的,因为修饰静态方法的时候,锁对象是类的Class对象,虽然我们创建了两个不同的实例对象, 但是实例对象都是公共一个Class对象,所以他们的锁是一样的,所以就线程安全了。

synchronized修饰代码块:

修饰代码块的时候,synchronized需要自己定义锁对象,当然也可以用实例对象或者Class对象,如下

public class CaculateSync {

    //共享数据
    public static int i = 0;
    private byte[] lock = new byte[0];
    private byte[] lock1 = new byte[0];


    //修饰方法中的代码块
    private void addArea() {
        //锁可以是任意对象
        synchronized (lock) {
            i++;
        }
    }

    //修饰方法中的代码块
    private void addArea1() {
        //锁可以是任意对象
        synchronized (lock1) {
            i++;
        }
    }

    private Runnable CaculateRnn = new Runnable() {

        @Override
        public void run() {
            for (int j = 0; j < 1000000; j++) {
                //调用静态方法
                addArea();
            }
        }
    };

    private Runnable CaculateRnn1 = new Runnable() {

        @Override
        public void run() {
            for (int j = 0; j < 1000000; j++) {
                //调用静态方法
                addArea1();
            }
        }
    };


    public Thread test() {
        try {
            Thread thread1 = new Thread(CaculateRnn);
            Thread thread2 = new Thread(CaculateRnn1);

            thread1.start();
            thread2.start();

            thread1.join();
            thread2.join();

            //i = 0;

        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        return null;
    }

}

通过测试得知,同样的道理,当多线程访问具有相同锁的代码块时也是安全的。上面展示了synchronized常用的使用方法,下面我们来探究下一synchronized的底层原理。

synchronized底层语义原理

在java虚拟机中,java对象的内存结构主要分为:对象头、实例数据、对齐填充三部分。如下:

其中我们重点关注对象头,它是synchronized实现同步的基础,在这里我们不去过多地分析对象头的数据结构,我们就知道在对象头的Mark World中保存着一个叫管程(Monitor)的东西,那我们先来了解一下什么是管程(Monitor),在java虚拟机中,monitor是由ObjectMonitor实现的,其主要数据结构如下:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //记录个数
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL; //指向持有ObjectMonitor对象的线程
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

其实monitor就可以认为是多线程同步的时候所谓的锁,谁持有锁谁就取得代码的执行资格。我们重点关注上面的几个变量:_count、_Waitset、_EntryList、_owner。其中_Waitset和_EntryList是两个队列,分别用来保存等待的和就绪状态的线程。_owner用来指向持有ObjectMonitor对象的线程,即指向取得锁的那个线程。到这里我们可以猜想到,其实synchronized实现同步的原理也就跟这个ObjectMonitor有直接的关系了。

回到前面的对象头,对象头存在于每一个java对象中,而对象头的Mark Word部分保存着指向monitor对象地址的指针,所以说每个对象都会关联一个monitor对象,这也是为什么任意对象都可以作为锁的原因。比如我们的synchronized修饰非静态方法的时候, 此时的锁就是方法所在类的实例对象,那么当这个实例对象关联的monitor的_owner指向某个确定的线程的时候,就代表这个线程拥有了执行资格。

那么synchronized又是如何让线程去持有和释放对象锁的monitor的呢?其中这里又分显示同步和隐式同步,显示同步有明确的 monitorenter 和 monitorexit 指令,比如同步代码块就是显示同步。隐式同步没有明确的进入和退出指令,比如synchronized同步方法的时候,它是用 ACC_SYNCHRONIZED 标志来实现的。下面我们分别利用反编译去看一下synchronized修饰代码块和修饰方法的字节码信息,看能不能在其中找到关于显示同步和隐式同步的有关信息。

synchronized的显示同步(同步代码块):

我们通过编译下面代码再反编译其class文件查看字节码信息:

public class Sync {

    private static int i = 0;

    private void add() {
        synchronized (this) {
            i++;
        }

    }
}

上面是同步代码块的使用,按照猜想我们反编译其class文件以后,会看到对应的monitorenter 和 monitorexit 指令,下面我们来看看,贴出关键信息:

private void add();
    descriptor: ()V
    flags: ACC_PRIVATE
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter  //进入同步代码块
         4: getstatic     #2                  // Field i:I
         7: iconst_1
         8: iadd
         9: putstatic     #2                  // Field i:I
        12: aload_1
        13: monitorexit  //退出同步代码块
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit  //退出同步代码块
        20: aload_2
        21: athrow
        22: return
      Exception table:
         from    to  target type
             4    14    17   any
            17    20    17   any
      LineNumberTable:
        line 11: 0
        line 12: 4
        line 13: 12
        line 15: 22
      StackMapTable: number_of_entries = 2

从上面可以看到,信息中可以明显地看到一个monitorenter进入指令和两个monitorexit退出指令,为什么会有两个退出指令?首先程序正常执行完毕有一个monitorexit,第二个退出指令其实是当执行发生异常的时候,我们也需要monitorexit来退出线程。我们来解析一下线程基于指令的执行情况,首先指令monitorenter指向同步代码块的开始位置,monitorexit则指向同步代码块的结束位置。当执行monitorenter进入指令时,当前线程将试图获取对象锁所对应的monitor的持有权,当对象锁的monitor的计数为0,即_count等于0时,此时线程就可以成功取得monitor,那么计数值加1,其他线程被阻塞直到当前线程执行完毕,即线程执行到了monitorexit指令,线程将释放锁并且计数值为0。

synchronized隐式同步(同步方法):

方法级的同步是隐式的,即jvm会通过方法是否有设置了ACC_SYNCHRONIZED常量标识来区分此方法是否为同步方法。如果设置了,则执行线程将持有monitor对象, 然后执行方法,在执行方法期间,其他线程是无法获取同一个monitor的,直到方法执行完毕释放monitor,其他线程才有机会获取。如果方法执行的过程了发生了异常,并且异常不是在方法内处理,那么这个同步方法持有的monitor也将在异常抛出到同步方法外是自动释放。下面我们来看看字节码层面的相关信息:

public class Sync {

    private static int i = 0;

    private synchronized void add() {
        i++;
    }
}

反编译其class文件贴出关键信息如下:

 private synchronized void add();
   descriptor: ()V
    //方法标识为private,其中ACC_SYNCHRONIZED代表该方法为同步方法
   flags: ACC_PRIVATE, ACC_SYNCHRONIZED
   Code:
     stack=2, locals=1, args_size=1
        0: getstatic     #2                  // Field i:I
        3: iconst_1
        4: iadd
        5: putstatic     #2                  // Field i:I
        8: return
     LineNumberTable:
       line 11: 0
       line 12: 8

 static {};
   descriptor: ()V
   flags: ACC_STATIC
   Code:
     stack=1, locals=0, args_size=0
        0: iconst_0
        1: putstatic     #2                  // Field i:I
        4: return
     LineNumberTable:
       line 8: 0
}

我们可以看到同步方法中并没有进入和退出指令,确实有ACC_SYNCHRONIZED标识,这个就是synchronized同步方法的原理了。上面我们讲解的synchronized的同步是基于对象管理的monitor来实现的,而monitor是基于底层操作系统的Mutex Lock实现的,而操作系统实现线程的切换需要的时间成本比较高,所以说此时的synchronized实现效率比较低。在java6以后,为了减少获得锁和释放锁带来的性能问题,引入了偏向锁和轻量级锁。

synchronized对锁的优化:

偏向锁

偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。下面我们接着了解轻量级锁。

轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

自旋锁
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。

锁消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。
 

好了,我们对synchronized的讲解就且到这里吧!

猜你喜欢

转载自blog.csdn.net/liuxingrong666/article/details/84562861