深入理解Java多线程-synchronized的使用及其原理

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

先来看看下面的一个例子:

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,为什么会这样的呢?首先i++的操作并不具备原子性,执行要分两步进行,首先读取i的值,然后再执行加1。在这个例子中,有两个线程thread1和thread2同时读取和操作共享数据i,假设thread1读取了数据i,此时i的值为100,在未进行操作和更新数据i的时候线程thread2获得了执行权也读取了i=100的数据,然后thread1和thread2都在i=100的基础上加1去更新数据,最后i的值为101,虽然两个线程一共进行了两次加1操作,但最后i的值确只有101,这相当于操作失败了一次。所以说上面的程序存在了线程安全的问题,最后的结果就小于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还可以修饰静态方法和代码块。修饰静态方法的时候锁对象是当前类的Class对象,修饰代码块的时候,锁可以是任意对象,由自己定义

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

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

2.修饰静态方法,锁对象为当前类的Class对象,进入同步代码块需要取得当前类的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 ;
  }

主要来看ObjectMonitor的这几个变量:_count、_Waitset、_EntryList、_owner。其中_Waitset和_EntryList是两个队列,分别用来保存等待状态和就绪状态的线程;_owner用来指向持有ObjectMonitor对象的线程,即指向取得锁的那个线程;_count代表当前monitor持有线程的数量;由此可以猜想到,synchronized实现同步的原理也就跟这个ObjectMonitor有直接的关系了

对象头存在于每一个java对象中,而对象头的Mark Word部分保存着一个monitor对象的指针,所以说每个对象都会关联一个monitor对象,这也是为什么任意对象都可以作为锁的原因了,因为有了monitor对象,我们就可以通过它的_owner变量指向的线程来识别当前哪个线程持有了这个对象锁

比如我们的synchronized修饰非静态方法的时候, 此时的对象锁是当前类的实例对象,那么这个实例对象的对象头保存着一个monitor(管程),当这个monitor的_owner指向某个确定的线程的时候,就代表这个线程拥有了当前锁对象

那么synchronized作为一个关键字,又是如何让程序拥有多线程同步的功能的呢?它底层是如何实现的?其实应该是编译器在编译的时候根据synchronized关键字的识别,来给程序添加对应的标识或指令,通过这些标识底层在执行的时候就知道当前方法或代码块是否需要同步

根据synchronized修饰的是方法还是代码块,又可以将同步分为显式同步和隐式同步,显式同步在程序中有明确的 monitorenter 和 monitorexit 指令,比如同步代码块就是显示同步;隐式同步没有明确的进入和退出指令,它是用 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进入指令的时候,代表当前线程正试图取得同步代码块的执行权,此时需要查看synchronized的对象锁的monitor的计数是否为0,即ObjectMonitor的_count是否等于0,如果为0就代表当前线程可以取得锁,然后monitor的_owner就指向当前线程,此时计数值_count加1,其他线程被阻塞直到当前线程执行完毕,当线程执行到了退出指令monitorexit的时候,线程将释放锁并且计数值重置为0。如果一个线程执行到代码的进入指令monitorenter的时候, 发现对象锁的count大于0的时候,是要被阻塞的

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

方法的同步是隐式的,即jvm会通过方法是否有设置了ACC_SYNCHRONIZED常量标识来区分此方法是否为同步方法。当一个线程执行到一个方法到时候,会根据是否有ACC_SYNCHRONIZED标识来确认当前方法是否为同步方法,如果是同步方法,那么同步原理跟上面的显示同步是一样的,不同只是识别开始同步和结束同步不一样,显示同步通过同步进入指令和退出指令来识别,隐式同步通过标识ACC_SYNCHRONIZED来识别进入同步,方法执行结束则结束同步

   下面我们来看看字节码层面的相关信息:

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对锁的优化:

1.偏向锁

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

2.轻量级锁

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

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

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值