弄清楚synchronized的原理

1、概述:

在学习synchronized前,先看看线程得概念和作用。
线程安全是并发编程中的重要因素,应该注意的是,造成线程安全的主要
原因有两个,一是存在的共享数据(临界资源),二是在多条线程共同操作
共享数据。

为了解决这个问题,必须设计一个方案:当存在多个线程操作共享数据时,
需要保证同一时刻有且只有一个线程操作共享数据,其他线程必须等到
该线程处理之后才能执行,这种方式叫互斥锁;所谓的互斥锁就是能达到
互斥访问目的的锁,也可以说当一个共享数据被当前正在访问的线程加上了
互斥锁后,在同一个时刻,其他线程处于等待状态,直到该线程处理完成后释放该锁。
在java中,用synchronized关键字可以保证在同一时刻,只有一个线程可以执行某个
方法或者某个代码块,同时还应该注意synchronized另一个重要的作用,什么作用呢?
synchronized可保证一个线程的变化被其他线程所看到(保证可见性)。

2、synchronized的三种应用方式

(1)修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁。
(2)修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁。
(3)修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库之前要获得给定对象的锁。

3、synchronized作用于实例方法

所谓的实例对象锁就是用synchronized修饰实例对象中的实例方法,注意是实例方法不包括静态方法,如下

public class AccountingSync implements Runnable{
    //共享资源(临界资源)
    static int i=0;

    /**
     * synchronized 修饰实例方法
     */
    public synchronized void increase(){
        i++;
    }
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
            increase();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        AccountingSync thread=new AccountingSync();
        Thread t1=new Thread(thread);
        Thread t2=new Thread(thread);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
    /**
     * 输出结果:
     * 2000000
     */
}

上述代码中,我们开启两个线程操作同一个共享资源即变量i,由于i++;操作并不具备原子性,
该操作是先读取值,然后写回一个新值,相当于原来的值加上1,分两步完成,
如果第二个线程在第一个线程读取旧值和写回新值期间读取i的域值,
那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加1操作,
这也就造成了线程安全失败,因此对于increase方法必须使用synchronized修饰,
以便保证线程安全。此时我们应该注意到synchronized修饰的是实例方法increase,
在这样的情况下,当前线程的锁便是实例对象thread,注意Java中的线程同步锁可以是任意对象。
从代码执行结果来看确实是正确的,倘若我们没有使用synchronized关键字,其最终输出结果就很可能小于2000000
,这便是synchronized关键字的作用。这里我们还需要意识到,当一个线程正在访问一个对象的 synchronized 实例方法,
那么其他线程不能访问该对象的其他 synchronized 方法,毕竟一个对象只有一把锁,当一个线程获取了该对象的锁之后,
其他线程无法获取该对象的锁,所以无法访问该对象的其他synchronized实例方法,但是其他线程还是
可以访问该实例对象的其他非synchronized方法,当然如果是一个线程 A 需要访问实例对象 obj1 的
synchronized 方法 f1(当前对象锁是obj1),另一个线程 B 需要访问实例对象 obj2 的 synchronized 方法
f2(当前对象锁是obj2),这样是允许的,因为两个实例对象锁并不同相同,此时如果两个线程操作数据并非共享的,
线程安全是有保障的,遗憾的是如果两个线程操作的是共享数据,那么线程安全就有可能无法保证了,如下代码将演示出该现象

public class AccountingSyncBad implements Runnable{
    static int i=0;
    public synchronized void increase(){
        i++;
    }
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
            increase();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        //new新实例
        Thread t1=new Thread(new AccountingSyncBad());
        //new新实例
        Thread t2=new Thread(new AccountingSyncBad());
        t1.start();
        t2.start();
        //join含义:当前线程A等待thread线程终止之后才能从thread.join()返回
        t1.join();
        t2.join();
        System.out.println(i);
    }
}

上述代码与前面不同的是我们同时创建了两个新实例AccountingSyncBad,然后启动两个
不同的线程对共享变量i进行操作,但很遗憾操作结果是1452317而不是期望结果2000000,因为上述代码犯了严重的错误,虽然我们使用synchronized修饰了increase方法,但却new了两个不同的实例对象,这也就意味着存在着两个不同的实例对象锁,因此
t1和t2都会进入各自的对象锁,也就是说t1和t2线程使用的是不同的锁,因此线程安全
是无法保证的。解决这种困境的的方式是将synchronized作用于静态的increase方法,
这样的话,对象锁就当前类对象,由于无论创建多少个实例对象,但对于的类对象拥有
只有一个,所有在这样的情况下对象锁就是唯一的。下面我们看看如何使用将synchronized作用于静态的increase方法。

4.synchronized作用于静态方法

当synchronized作用于静态方法时,其锁就是当前类的class对象锁。
由于静态成员不专属于任何一个实例对象,是类成员,因此通过class对象锁可以控制静态 成员的并发操作需要注意的是如果一个线程A调用一个实例对象的非static synchronized方法,而线程B需要调用这个实例
对象所属类的静态 synchronized方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的class对象,而访问非静态 synchronized 方法占用的锁是当前实例对象锁,看如下代码!

public class Thread1 extends Thread{
    //共享资源(临界资源)
    static int i = 0 ;

    /**
     * synchronized修饰实例方法
     */
    public synchronized void increase(){
        i ++ ;
    }
    /**
     * synchronized修饰静态方法
     */
    public static  synchronized void increaseOb(){
        i ++;
    }
    @Override
    public   void run() {
        for(int j = 0 ;j < 10000000 ; j ++){
            increaseOb();
        }
        System.out.println(i);
    }
    public static void main(String[] args) throws Exception{
        Thread1 thread = new Thread1();
        Thread t1 = new Thread(new Thread1()) ;
        Thread t2 = new Thread(new Thread1()) ;
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }

}

由于synchronized关键字修饰的是静态increase方法,
与修饰实例方法不同的是,其锁对象是当前类的class对象。
注意代码中的increase4Obj方法是实例方法,其对象锁是当前实例对象
,如果别的线程调用该方法,将不会产生互斥现象,毕竟锁对象不同,
但我们应该意识到这种情况下可能会发现线程安全问题(操作了共享静态变量i)。
5、synchronized同步代码块
除了使用关键字修饰实例方法和静态方法外,还可以使用同步代码块,
在某些情况下,我们编写的方法体可能比较大,同时存在一些比较耗时的操作,
而需要同步的代码又只有一小部分,如果直接对整个方法进行同步操作,可能会得不偿失,此时我们可以使用同步代码块的方式对需要同步的代码进行包裹,这样就无需对整个方法进行同步操作了,同步代码块的使用示例如下:

public class Thread1 extends Thread{
    static Thread1 thread = new Thread1();
    //共享资源(临界资源)
    static int i = 0 ;

    /**
     * synchronized修饰实例方法
     */
    public synchronized void increase(){
        i ++ ;
    }
    /**
     * synchronized修饰静态方法
     */
    public static  synchronized void increaseOb(){
        i ++;
    }
    @Override
    public   void run() {
        /**
         * synchronized修饰同步代码块
         */
        synchronized (thread){
            for(int j = 0 ;j < 10000000 ; j ++){
                i ++;
            }
        }

        System.out.println(i);
    }
    public static void main(String[] args) throws Exception{
        Thread t1 = new Thread(new Thread1()) ;
        Thread t2 = new Thread(new Thread1()) ;
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
}

从代码看出,将synchronized作用于一个给定的实例对象thread,
即当前实例对象就是锁对象,每次当线程进入synchronized包裹的代码
块时就会要求当前线程持有thread实例对象锁,如果当前有其他线程
正持有该对象锁,那么新到的线程就必须等待,这样也就保证了每次只
有一个线程执行i++;操作。当然除了thread作为对象外,我们还可以
使用this对象(代表当前实例)或者当前类的class对象作为锁,如下代码:

//this,当前实例对象锁
synchronized(this){
    for(int j=0;j<1000000;j++){
        i++;
    }
}

//class对象锁
synchronized(AccountingSync.class){
    for(int j=0;j<1000000;j++){
        i++;
    }
}

了解完synchronized的基本含义及其使用方式后,下面我们将进一步深入理解synchronized的底层实现原理。

6、synchronized底层语义原理

在java虚拟机中的同步(Synchronization)基于进入和退出管程(Monitor)对象实现;
monitorenter 和 monitorexit 指令,即同步代码块,显式还是隐式同步。在java中同步用到的最多地方可能是被synchronized修饰的同步方法。同步方法 并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法的 ACC_SYNCHRONIZED 标志来隐式实现的。

7、理解Java对象头与Monitor

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。
而对于顶部,则是Java头对象,它实现synchronized的锁对象的基础,这点我们重点分析它,一般而言,synchronized使用的锁对象是存储在Java对象头里的,jvm中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),其主要结构是由Mark Word 和 Class Metadata Address 组成:

虚拟机位数 头对象结构 说明
32/64bit Mark Word 存储对象的hashCode、锁信息或分代年龄或GC标志等信息
32/64bit Class Metadata Address 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。
其中Mark Word在默认情况下存储着对象的HashCode、分代年龄、锁标记位等以下是32位JVM的Mark Word默认存储结构;
锁状态 25bit 4bit 1bit是否是偏向锁 2bit锁标志位
无锁状态 对象HashCode 对象分代年龄 0 01
8、synchronized方法底层原理
方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。
JVM可以从方法常量池中的方法表结构(method_info Structure) 中的 ACC_SYNCHRONIZED
访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会 检查方法的 ACC_SYNCHRONIZED
访问标志是否被设置,如果设置了,执行线程将先持有monitor(虚拟机规范中用的是管程一词),
然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。在方法执行期间,
执行线程持有了monitor,其他任何线程都无法再获得同一个monitor。如果一个同步方法执行期间抛 出了异常,
并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放。
从字节码中可以看出,synchronized修饰的方法并没有monitorenter指令和monitorexit指令,
取得代之的确实是ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED
访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。这便是synchronized锁在同步代码块
和同步方法上实现的基本原理。同时我们还必须注意到的是在Java早期版本中,synchronized属于重量级锁,
效率低下,因为监视器锁(monitor)是依赖于底层的操作系统的Mutex Lock来实现的,而操作系统实现线程
之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,
这也是为什么早期的synchronized效率低的原因。庆幸的是在Java 6之后Java官方对从JVM层面对synchronized较大优化,
所以现在的synchronized锁效率也优化得很不错了,Java 6之后,为了减少获得锁和释放锁所带来的性能消耗,
引入了轻量级锁和偏向锁,接下来我们将简单了解一下Java官方在JVM层面对synchronized锁的优化。

9、Java虚拟机对synchronized的优化

锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,
再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级,关于重量级锁,
前面我们已详细分析过,下面我们将介绍偏向锁和轻量级锁以及JVM的其他优化手段,这里并不打算深入到每个锁
的实现和转换过程更多地是阐述Java虚拟机所提供的每个锁的核心优化思想,毕竟涉及到具体过程比较繁琐,如需
了解详细过程可以查阅《深入理解Java虚拟机原理》。
(1)偏向锁
偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,
锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)
的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构
也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,
从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁
。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,
因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,
而是先升级为轻量级锁。
(2)轻量级锁
倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),
此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,
在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,
如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值