synchronized的介绍,使用和原理

使用方法

  1. 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码

    • synchronized(this|object) 表示进入同步代码前要获得给定对象的锁
    • synchronized(类.class) 表示进入同步代码前要获得 当前 class对象 的锁
  2. 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象,等价于synchronized(this|object)

  3. 修改一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象,等价于 synchronized(类.class)

    class Test{
        //锁住实例对象
        public synchronized void test() {

        }
    }
    //等价于
    class Test{
        public void test() {
            synchronized(this) {

            }  
        }
    }
//---------------- --------------------------------------------------------------------------------
    class Test{
        //锁住类对象
        public synchronized static void test() {
        }
    }
   // 等价于
    class Test{
        public static void test() {
            synchronized(Test.class) {

            }
        }
    }

作用

  • 原子性所谓原子性就是指一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。synchronized修饰的类或对象的所有操作都是原子的,因为在执行操作之前必须先获得类或对象的锁,直到执行完才能释放。
  • 可见性可见性是指多个线程访问一个资源时,该资源的状态、值信息等对于其他线程都是可见的。 synchronized和volatile都具有可见性,其中synchronized对一个类或对象加锁时,一个线程如果要访问该类或对象必须先获得它的锁,而这个锁的状态对于其他任何线程都是可见的,并且在释放锁之前会将对变量的修改刷新到共享内存当中,保证资源变量的可见性。
  • 有序性有序性值程序执行的顺序按照代码先后执行 synchronized和volatile都具有有序性,Java允许编译器和处理器对指令进行重排,但是指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性。synchronized保证了每个时刻都只有一个线程访问同步代码块,也就确定了线程执行同步代码块是分先后顺序的,保证了有序性。


案例

看以下案例的时候,可以将案例一,二看为一组,案例三,四看为一组,案例五,六看为一组。

案例一

class Number{
	 public synchronized void a() {
	 	log.debug("1");
	 }
	 public synchronized void b() {
	 	log.debug("2");
	 }
}
public static void main(String[] args) {
	 Number n1 = new Number();
	 new Thread(()->{ n1.a(); }).start();
	 new Thread(()->{ n1.b(); }).start();
}

输出:12 或 21
分析:两个线程获取的都是同一实例对象的锁,会互斥访问

案例二

class Number{
	 public synchronized void a() {
		 sleep(1);
		 log.debug("1");
	 }
	 public synchronized void b() {
		 log.debug("2");
	 }
}
public static void main(String[] args) {
	 Number n1 = new Number();
	 Number n2 = new Number();
	 new Thread(()->{ n1.a(); }).start();
	 new Thread(()->{ n2.b(); }).start();
}

输出:2 1s 后 1
分析:两个线程获取的是不同实例对象的锁,可以同时进入两个方法。





案例三

class Number{
	 public static synchronized void a() {
		 sleep(1);
		 log.debug("1");
	 }
	 public static synchronized void b() {
	 	log.debug("2");
	 }
}
public static void main(String[] args) {
	 Number n1 = new Number();
	 new Thread(()->{ n1.a(); }).start();
	 new Thread(()->{ n1.b(); }).start();
}

输出:1s 后12, 或 2 1s后 1
分析:两个线程获取的都是同一类对象的锁,会互斥访问

案例四

class Number{
	 public static synchronized void a() {
		 sleep(1);
		 log.debug("1");
	 }
	 public static synchronized void b() {
		 log.debug("2");
	 }
}
public static void main(String[] args) {
	 Number n1 = new Number();
	 Number n2 = new Number();
	 new Thread(()->{ n1.a(); }).start();
	 new Thread(()->{ n2.b(); }).start();
}

输出:1s 后12, 或 2 1s后 1
分析:两个线程获取的都是同一类对象的锁,会互斥访问





案列五

class Number{
	 public static synchronized void a() {
		 sleep(1);
		 log.debug("1");
	 }
	 public synchronized void b() {
		 log.debug("2");
	 }
}
public static void main(String[] args) {
	 Number n1 = new Number();
	 new Thread(()->{ n1.a(); }).start();
	 new Thread(()->{ n1.b(); }).start();
}

输出:2 1s后1
分析:一个线程获取的是类对象锁,一个线程获取的是实例对象锁,可以同时进入两个方法。

案例六

class Number{
	 public static synchronized void a() {
		 sleep(1);
		 log.debug("1");
	 }
	 public synchronized void b() {
	 	log.debug("2");
	 }
}
public static void main(String[] args) {
	 Number n1 = new Number();
	 Number n2 = new Number();
	 new Thread(()->{ n1.a(); }).start();
	 new Thread(()->{ n2.b(); }).start();
}

输出:2 1s 后 1
分析:一个线程获取的是类对象锁,一个线程获取的是实例对象锁,可以同时进入两个方法。


原理

synchronized实际上利用对象保证了临界区代码的原子性,临界区内的代码在外界看来是不可分割的,不会被线程切换所打断。

synchronized是一种可重入锁,以下将从偏向锁,轻量级锁,锁膨胀,重量级锁,自旋这几个方面来讲解。

在此之前,需要了解两个相关概念:对象头和管程。

Java对象头

以 32 位虚拟机为例,普通对象的对象头结构如下,其中的Klass Word为指针,指向对应的Class对象;

1583651065372

数组对象

1583651088663

其中 Mark Word 结构为

所以一个对象的结构如下:

1583678624634

Monitor

Monitor被翻译为监视器或者说管程

每个java对象都可以关联一个Monitor,如果使用synchronized给对象上锁(当锁膨胀变为重量级锁的时候),该对象头的Mark Word中就被设置为指向Monitor对象的指针

1583652360228

  • 刚开始时Monitor中的Owner为null
  • 当Thread-2 执行synchronized(obj){}代码时就会将Monitor的所有者Owner 设置为 Thread-2,上锁成功,Monitor中同一时刻只能有一个Owner
  • 当Thread-2 占据锁时,如果线程Thread-3,Thread-4也来执行synchronized(obj){}代码,就会进入EntryList中变成BLOCKED状态
  • Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争时是非公平的
  • 图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足,调用wait方法,即可进入WaitSet变为WAITING状态/TIME_WAITING状态。

WaitSet中的线程会在Owner线程调用notify或者notifyAll时唤醒,进入EntryList等待获取锁;或者等待时间结束后进入EntryList。

注意:synchronized 必须是进入同一个对象的 monitor 才有上述的效果不加 synchronized 的对象不会关联监视器,不遵从以上规则

字节码层面分析

  • 同步代码块采用monitorentermonitorexit指令显式的实现。
  • 同步方法则使用ACC_SYNCHRONIZED标记符隐式的实现。
同步代码块
static final Object lock=new Object();
    static int counter = 0;
    public static void main(String[] args) {
        synchronized (lock) {
            counter++;
        }
    }

反编译后的部分字节码

 # 取得lock的引用(synchronized开始了)
 0 getstatic #2 <com/concurrent/test/Test17.lock>
 # 复制操作数栈栈顶的值放入栈顶,即复制了一份lock的引用
 3 dup    
 # 操作数栈栈顶的值弹出,即将lock的引用存到局部变量表中
 4 astore_1
 # 尝试获取对象锁。如果这个对象没有被锁定,或当前线程已经持有锁,就把锁的计数器加 1
 5 monitorenter
 6 getstatic #3 <com/concurrent/test/Test17.counter>
 9 iconst_1
10 iadd
11 putstatic #3 <com/concurrent/test/Test17.counter>
# 从局部变量表中取得lock的引用,放入操作数栈栈顶
14 aload_1
# 将锁计数器减 1。一旦计数器为 0 锁随即就被释放。
15 monitorexit
# 下面是异常处理指令,可以看到,如果出现异常,也能自动地释放锁
16 goto 24 (+8)
19 astore_2
20 aload_1
21 monitorexit
22 aload_2
23 athrow
24 return

同步方法
public synchronized void add(){
	i++;
}

这个标志用来告诉JVM这是一个同步方法,在进入该方法之前先获取相应的锁,锁的计数器加1,方法结束后计数器-1。


轻量级锁

轻量级锁的使用场景是:如果一个对象虽然有多个线程要对它进行加锁,但是加锁的时间是错开的(也就是没有人可以竞争的),那么可以使用轻量级锁来进行优化。
轻量级锁对使用者是透明的,即语法仍然是synchronized,假设有两个方法同步块,利用同一个对象加锁。

static final Object obj = new Object();
public static void method1() {
     synchronized( obj ) {
         // 同步块 A
         method2();
     }
}
public static void method2() {
     synchronized( obj ) {
         // 同步块 B
     }
}
  1. 每次指向到synchronized代码块时,都会创建锁记录(Lock Record)对象,每个线程都会包括一个锁记录的结构,锁记录内部可以储存对象的Mark Word和对象引用reference

    1583755737580

  2. 让锁记录中的Object reference指向对象,并且尝试用cas(compare and sweep)替换Object对象的Mark Word ,将Mark Word 的值存入锁记录中

    1583755888236

  3. 如果cas替换成功,那么对象的对象头储存的就是锁记录的地址和状态01,如下所示

    1583755964276

  4. 如果cas失败,有两种情况

    1. 如果是其它线程已经持有了该Object的轻量级锁,那么表示有竞争,将进入锁膨胀阶段

    2. 如果是自己的线程已经执行了synchronized进行加锁,那么那么再添加一条 Lock Record 作为重入的计数

      1583756190177

  5. 当线程退出synchronized代码块的时候,如果获取的是取值为 null 的锁记录 ,表示有重入,这时重置锁记录,表示重入计数减一

    1583756357835

  6. 当线程退出synchronized代码块的时候,如果获取的锁记录取值不为 null,那么使用cas将Mark Word的值恢复给对象

    • 成功则解锁成功
    • 失败,则说明轻量级锁进行了锁膨胀或已经升级为重量级锁,进入重量级锁解锁流程

锁膨胀和重量级锁

如果在尝试加轻量级锁的过程中,cas操作无法成功,这是有一种情况就是其它线程已经为这个对象加上了轻量级锁,这是就要进行锁膨胀,将轻量级锁变成重量级锁。

重量级锁是依赖对象内部的monitor锁来实现的,而monitor又依赖操作系统的MutexLock(互斥锁)来实现的,所以重量级锁也被成为互斥锁

Q:重量级锁为什么开销大?

A:唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。

  1. 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁
    1583757433691
  2. 这时 Thread-1 加轻量级锁失败,进入锁膨胀流程
    即为对象申请Monitor锁,让Object指向重量级锁地址,然后自己进入Monitor 的EntryList 变成BLOCKED状态
    1583757586447
  3. 当Thread-0 推出synchronized同步块时,使用cas将Mark Word的值恢复给对象头,失败,那么会进入重量级锁的解锁过程,即按照Monitor的地址找到Monitor对象,将Owner设置为null,唤醒EntryList 中的Thread-1线程

自旋优化

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即在自旋的时候持锁的线程释放了锁),那么当前线程就可以不用进行上下文切换就获得了锁

  1. 自旋重试成功的情况

    1583758113724

  2. 自旋重试失败的情况,自旋了一定次数还是没有等到持锁的线程释放锁

    1583758136650

自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势

在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。

Java 7 之后不能控制是否开启自旋功能


偏向锁

在轻量级的锁中,我们可以发现,如果同一个线程对同一个2对象进行重入锁时,也需要执行CAS操作,这是有点耗时滴,那么java6开始引入了偏向锁的东东,只有第一次使用CAS时将对象的Mark Word头设置为入锁线程ID,之后这个入锁线程再进行重入锁时,发现线程ID是自己的,那么就不用再进行CAS了

1583760728806

竞争激烈时不适合使用偏向锁,jvm会判断是否使用。

偏向状态

1583762169169

一个对象的创建过程

  1. 如果开启了偏向锁(默认是开启的),那么对象刚创建之后,Mark Word 最后三位的值101,并且这是它的Thread,epoch,age都是0,在加锁的时候进行设置这些的值.

  2. 偏向锁默认是延迟的,不会在程序启动的时候立刻生效,如果想避免延迟,可以添加虚拟机参数来禁用延迟:-XX:BiasedLockingStartupDelay=0来禁用延迟

  3. 注意:处于偏向锁的对象解锁后,线程 id 仍存储于对象头中

撤销偏向锁
hashcode方法

测试 hashCode:当调用对象的hashcode方法的时候就会撤销这个对象的偏向锁,因为使用偏向锁时没有位置存hashcode的值了

使用虚拟机参数-XX:BiasedLockingStartupDelay=0 ,确保我们的程序最开始使用了偏向锁!但是结果显示程序还是使用了轻量级锁。

    public static void main(String[] args) throws InterruptedException {
        Test1 t = new Test1();
        t.hashCode();
        test.parseObjectHeader(getObjectHeader(t));

        synchronized (t){
            test.parseObjectHeader(getObjectHeader(t));
        }
        test.parseObjectHeader(getObjectHeader(t));
    }

输出结果

biasedLockFlag (1bit): 0
	LockFlag (2bit): 01
LockFlag (2bit): 00
biasedLockFlag (1bit): 0
	LockFlag (2bit): 01

其它线程使用对象,批量重偏向,批量撤销

在没有发生竞争的情况下,当另一个线程获取对象锁时,偏向锁就会上升为轻量锁。(可以理解为本来是偏向Thread-1的,现在Thread-2获得了对象锁,就撤销了对Thread-1的偏向,变为轻量级锁)

批量重偏向如果超过20个对象对同一个线程如Thread-1撤销偏向时,那么第20个及以后的对象可以将撤销对Thread-1的偏向变为偏向Thread-2。

批量撤销如果线程撤销超过40次,jvm知道竞争太激烈,整个类的所有对象都会变为不可偏向,新建的对象也是不可偏向的。


调用 wait/notify

会使对象的锁变成重量级锁,因为调用Wait方法的锁一定是重量级锁


锁消除

public class MyBenchmark {
    static int x = 0;
    @Benchmark
    public void a() throws Exception {
        x++;
    }
    @Benchmark
    // JIT  即时编译器
    public void b() throws Exception {
        Object o = new Object();
        synchronized (o) {
            x++;
        }
    }
}

在b()方法中,局部变量o不会被共享,加锁没有意义,JIT就会将锁去掉,执行的时候和a()一样。

锁粗化

synchronized(this) {

}

synchronized(this) { 

}

 
synchronized(this) {

}

JIT编译器如果发现有代码里连续多次加锁释放锁的代码,会给合并为一个锁,就是锁粗化,把一个锁给搞粗了,避免频繁多次加锁释放锁

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值