Java并发编程 V - 并发的万能钥匙synchronized

Java并发编程 I - 并发问题的源头
Java并发编程 II - 没有共享就没有伤害(ThreadLoacl)
Java并发编程 III - 让共享数据只读(final关键字)
Java并发编程 IV - volatile关键字与Atomic类
Java并发编程 V - 并发的万能钥匙synchronized
Java并发编程 VI - 线程生命周期与线程间的协作
Java并发编程 VII - Lock

synchronized关键字作用

Java中管程的实现是synchronized关键字

在并发环境中,synchronized关键字保证修饰部分的可见性原子性有序性

synchronized关键字能够修饰方法代码块


synchronized关键字使用

public class SyncTest {
    private int i, j;
    private static int k;

    //synchronized修饰 使用this来代码块
    public void test_1(){
        String name = Thread.currentThread().getName();
        System.out.println("test_1 111 " + name);
        synchronized (this){
            System.out.println("test_1 222 " + name);
            try {
                TimeUnit.SECONDS.sleep(1);
                i++;
            } catch (InterruptedException e) { e.printStackTrace(); }
            System.out.println("test_1 333 " + name);
        }
        System.out.println("test_1 444 " + name);
    }

  	//synchronized修饰 方法
    public synchronized void test_2(){
        String name = Thread.currentThread().getName();
        System.out.println("test_2 ---- 111 " + name);
        try {
            TimeUnit.SECONDS.sleep(1);
            j++;
            System.out.println("test_2 ---- 222 " + name);
        } catch (InterruptedException e) { e.printStackTrace(); }
        System.out.println("test_2 ---- 333 " + name);
    }

  	//synchronized修饰 静态方法
    public static synchronized void test_3(){
      	String name = Thread.currentThread().getName();
        System.out.println("test_3 ---- 111 " + name);
        try {
            TimeUnit.SECONDS.sleep(1);
            k++;
            System.out.println("test_3 ---- 222 " + name);
        } catch (InterruptedException e) { e.printStackTrace(); }
        System.out.println("test_3 ---- 333 " + name);
    }
  
  	//synchronized修饰 使用SyncTest.class来锁代码块
  	public void test_4(){
        String name = Thread.currentThread().getName();
        System.out.println("test_4 111 " + name);
        synchronized (SyncTest.class){
            System.out.println("test_4 222 " + name);
            try {
                TimeUnit.SECONDS.sleep(5);
                i++;
            } catch (InterruptedException e) { e.printStackTrace(); }
            System.out.println("test_4 333 " + name);
        }
        System.out.println("test_4 444 " + name);
    }
}

“对象锁”

“对象锁”只是一个抽象的概念,用来让我们方便理解synchronized的锁机制。

在并发环境下,“对象锁”只会影响同一个对象中的锁操作(如栗子2),而不会影响不同对象的锁操作(如栗子1)。

//栗子1============================================
SyncTest syncTest = new SyncTest();
new Thread(() -> syncTest.test_1(), "线程a").start();
SyncTest syncTest2 = new SyncTest();
new Thread(() -> syncTest2.test_1(), "线程b").start();
/*
输出 ->
17:26:13:188 test_1 111 线程a
17:26:13:188 test_1 111 线程b
17:26:13:189 test_1 222 线程a
17:26:13:189 test_1 222 线程b
17:26:14:193 test_1 333 线程a
17:26:14:193 test_1 333 线程b
17:26:14:194 test_1 444 线程b
17:26:14:194 test_1 444 线程a
*/


//栗子2============================================
SyncTest syncTest = new SyncTest();
new Thread(() -> syncTest.test_1(), "线程a").start();
new Thread(() -> syncTest.test_2(), "线程b").start();
/*
输出 ->
17:27:16:514 test_2 ---- 111 线程b
17:27:16:514 test_1 111 线程a
17:27:17:518 test_2 ---- 222 线程b
17:27:17:518 test_2 ---- 333 线程b
17:27:17:519 test_1 222 线程a
17:27:18:520 test_1 333 线程a
17:27:18:521 test_1 444 线程a
  
线程a中执行的test_1()中锁的是代码块,为啥会影响线程b执行test_2()呢?
synchronized(this)、synchronized(SyncTest.this)都是“对象锁”,也就说对象内所有的出现
“对象锁”的地方都会受影响,只要有某个线程拿到这个对象锁,其他线程都需要等待,直到该线程释放这个锁。

synchronized void test_2(){ ... }
↓↓↓↓↓↓↓↓↓↓↓↓相当于↓↓↓↓↓↓↓↓↓↓↓↓
void test_2(){
  synchronized(SyncTest.this){ ... }
}
*/

自定义“对象锁”
private int i, j;

public void test_a(){
		synchronized (this){ i++; }
}

public void test_b(){
		synchronized (this){ j++; }
}

public void test_c(){
		synchronized (this){ i++; j++; }
}


SyncTest syncTest = new SyncTest();
new Thread(() -> syncTest.test_a(), "线程a").start();
new Thread(() -> syncTest.test_b(), "线程b").start();

从上边例子看出,test_a方法操作共享变量i,而test_b方法操作共享变量j。两者操作的共享变量都不一样,这个时候使用synchronized (this),就会导致线程a在执行的时候,可能会出现线程b需要等待的情况。

那为什么synchronized(this)或锁整个对象呢?那是因为防止多个线程在执行的时候操作相同的共享变量,例如下列的情况。

SyncTest syncTest = new SyncTest();
new Thread(() -> syncTest.test_c(), "线程c").start();
new Thread(() -> syncTest.test_a(), "线程a").start();

那有没有什么方法不同的代码上不同的锁呢?- 自定义“对象锁”

private int i, j;
private final Object lock_i = new Object();
private final Object lock_j = new Object();
public void test_a(){
		synchronized (lock_i){ i++; }
}

public void test_b(){
		synchronized (lock_j){ j++; }
}

“类锁”

“类锁”只是也一个抽象的概念。

在并发环境下,“类锁”不管是不是同一个对象,锁操作后其他线程执行到该synchronized处都需要等待(如栗子3)。

//栗子3============================================
SyncTest syncTest = new SyncTest();
new Thread(() -> syncTest.test_4(), "线程a").start();
SyncTest syncTest2 = new SyncTest();
new Thread(() -> syncTest2.test_4(), "线程b").start();

/*
输出 ->
20:01:25:331 test_4 111 线程a
20:01:25:331 test_4 111 线程b
20:01:25:332 test_4 222 线程a
20:01:30:335 test_4 333 线程a
20:01:30:335 test_4 444 线程a
20:01:30:335 test_4 222 线程b
20:01:35:341 test_4 333 线程b
20:01:35:342 test_4 444 线程b
*/

//栗子4============================================
new Thread(() -> SyncTest.test_3(), "线程a").start();
SyncTest syncTest = new SyncTest();
new Thread(() -> syncTest.test_4(), "线程b").start();

/*
输出 ->
20:09:26:072 test_4 111 线程b
20:09:26:072 test_3 ---- 111 线程a
20:09:27:077 test_3 ---- 222 线程a
20:09:27:077 test_3 ---- 333 线程a
20:09:27:077 test_4 222 线程b
20:09:32:081 test_4 333 线程b
20:09:32:082 test_4 444 线程b

static synchronized void test_3(){ ... }
↓↓↓↓↓↓↓↓↓↓↓↓相当于↓↓↓↓↓↓↓↓↓↓↓↓
void test_3(){
  synchronized(SyncTest.class){ ... }
}
*/

为什么synchronized修饰静态方法与修饰非静态方法的锁不一样

静态成员/方法不专属于任何一个实例对象,属于类的成员/方法,与实例对象无关。
前面介绍的“对象锁”应该叫做实例对象锁,每个实例对象都拥有自己的一个Monitor。而“类锁”是class对象锁,每个类只有一把“类锁”,也就是说一个类对象只有一个Monitor。
synchronized修饰非静态方法、synchronized(this)获取到的是当前这个实例对象的锁。
synchronized(obj)获取到的是obj这个实例对象的锁。
synchronized修饰静态方法、synchronized(XXX.class)获取到的是这个XXX类共用的类对象锁,这把锁只有一把,而且这把锁与实例对象的锁是分开的。


synchronized实现原理

private int i, j;
public void test_a(){
		synchronized (this){ i++; }
}
public synchronized void test_b(){ j++; }

//将上边的代码反编译后==========================
public void test_a();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=3, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: aload_0
         5: dup
         6: getfield      #2                  // Field i:I
         9: iconst_1
        10: iadd
        11: putfield      #2                  // Field i:I
        14: aload_1
        15: monitorexit
        16: goto          24
        19: astore_2
        20: aload_1
        21: monitorexit
        22: aload_2
        23: athrow
        24: return
      //省略...

  public synchronized void test_b();
    descriptor: ()V
    flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: dup
         2: getfield      #3                  // Field j:I
         5: iconst_1
         6: iadd
         7: putfield      #3                  // Field j:I
        10: return
      //省略...

通过反编译后可以看出:

对于同步方法,JVM采用ACC_SYNCHRONIZED标记符来实现同步。

对于同步代码块。JVM采用monitorentermonitorexit两个指令来实现同步。

不管是ACC_SYNCHRONIZED还是monitorentermonitorexit最终的都是基于Monitor实现的。


管程

管程 (Monitor,也叫监视器) ,管程指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。

Java提供了同步机制、互斥锁机制,这个机制保证了在同一时刻只有一个线程能访问共享资源。这个机制的保障来源于监视锁Monitor,每个对象都拥有自己的监视锁Monitor。

在这里插入图片描述

在这个建筑物(Monitor)中客人(线程)需要去到特殊房间(访问共享变量),需要先从走廊(Entry Set)开始排队。如果因某些原因该客人暂时因其他事情而无法脱身(线程被挂起),那么他将被送到专门用来等待的房间(Wait Set),这个房间可以在稍后再次进入那件特殊的房间。

上边的例子很好地解释了,Monitor的工作原理。他的义务是保证(同一时间)只有一个线程可以访问被保护的数据。


管程的实现

Monitor是基于C++实现的,其主要数据结构如下

//ObjectMonitor.hpp  带注释的都是几个关键属性
ObjectMonitor() {
    _header       = NULL;
    _count        = 0;			//用来记录该线程获取锁的次数
    _waiters      = 0,
    _recursions   = 0;			//锁的重入次数
    _object       = NULL;
    _owner        = NULL; 	//指向持有ObjectMonitor对象的线程
    _WaitSet      = NULL;		//存放处于wait状态的线程队列
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ;	//存放处于等待锁block状态的线程队列 
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
}

当多个线程同时访问一段同步代码时,首先会进入_EntryList队列中,当某个线程获取到对象的monitor后进入_Owner区域并把monitor中的_owner变量设置为当前线程,同时monitor中的计数器_count加1。即获得对象锁。

若持有monitor的线程调用wait()方法,将释放当前持有的monitor,_owner变量恢复为null_count自减1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示

在这里插入图片描述

详情可看:Hoills公众号-Moniter的实现原理


锁优化

在JDK1.6之前synchronized是直接通过调用ObjectMonitor的enterexit来实现的,这种锁被称之为重量级锁

为什么叫重量级锁?互斥同步对性能最大的影响是阻塞的实现,由于Java线程是映射到操作系统原生线程之上的,阻塞或唤醒一个线程就需要操作系统的帮忙。挂起线程和恢复线程的操作都需要转入内核态中完成,也就是从用户态转换到内核态,这些操作给系统的并发性能带来了很大的压力。如果synchronized保护的代码相对简单,那么状态转换消耗的时间可能远远大于受保护的代码执行时间。

在JDK1.6对锁进行了很多的优化,适应性自旋锁锁消除锁粗化轻量级锁偏向锁这些操作都是为了在并发环境下提高性能。


适应性自旋锁

虚拟机的开发团队发现,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。如果设备有多个处理器的话,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但是不放弃处理器的执行时间,看看持有锁的线程会不会很快释放锁。这个“稍微等一下”的过程就是自旋。

自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了

自旋锁在JDK1.4.2中就已经引入,只不过默认是关闭的,可以使用 :XX:+UseSpinning 参数来开启,自旋次数的默认值是10次,用户可以使用参数 -XX:PreBlockSpin 来更改。

JDK 1.6中就已经改为默认开启了,并且引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100个循环。另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理部资源。有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确。虚拟机就会变得越来越 “聪明” 了。


锁消除

JIT编译(即时编译,可以简单理解为当某段代码即将第一次被执行时进行编译)阶段,进行锁优化。通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁。

举个栗子:

//我写的代码
void test(){
 int i;
 Object obj = new Object();
 synchronized (obj){ 
   i++; 
 } 
}

//JIT编译阶段,发现该方法中加锁的对象obj生命周期只存在于test()中,并不会被其他线程所访问到。

//锁消除后↓↓↓↓↓
void test(){
 int i;
 i++;
}

常见案例:StringBuffer是线程安全的,如果JIT发现在代码中StringBuffer并不会存在竞争共享资源的话,会将其内部synchronized的修饰给去掉。


锁粗化

加锁不都是需要尽量减小锁的粒度吗,那为什么还需要锁粗化,这不矛盾了吗?

但是在一些特殊的场内,还真需要加粗锁才能达到优化的目的。如果在一段代码中连续的对同一个对象反复加锁解锁,其实是相对耗费资源的,这种情况可以适当放宽加锁的范围,减少性能消耗。

举个栗子:

//我写的代码
for(int i=0;i<100000;i++){  
   synchronized(this){  
       do();  
}


//JIT编译阶段,发现连续的操作都对同一个对象反复加锁和解锁,甚至加锁操作出现在循环体中
  
//锁粗化后↓↓↓↓↓
synchronized(this){  
   for(int i=0;i<100000;i++){  
       do();  
}

锁升级

Java对象头

每一个Java类,在被JVM加载的时候会给这个类创建一个instanceKlass,保存在方法区,用来在JVM层表示该Java类。当我们在代码中new一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了对象头以及实例数据。

对象头中存有一个叫markword,用于记录锁的信息。

在这里插入图片描述

详情可看:深入理解Java的对象头mark word

线程锁相关概念学习,锁消除,锁粗化


锁的四种状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态**(从低到高,只能升级无法降级)**


偏向锁

为什么要引入偏向锁?

大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。

偏向锁的升级

线程1访问代码块并获取锁时,对象从无锁状态变成偏向锁状态,并且记录下线程1的threadID。

偏向锁不会主动释放锁,因此当线程1再次获取锁的时候,只需用自己threadID与对象头中的threadID比较,如果一致即可获取到锁。

如果不一致(如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),查看对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁。

如果存活,那么查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。

(总结:当出现多个线程竞争,偏向锁会升级为轻量级锁。)


轻量级锁

为什么要引入轻量级锁?

竞争锁对象的线程不多而且线程持有锁的时间也不长的情景中。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。

轻量级锁升级:

线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;

如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。

但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。

注意:为了避免无用的自旋,轻量级锁一旦膨胀为重量级锁就不会再降级为轻量级锁了;偏向锁升级为轻量级锁也不能再降级为偏向锁。一句话就是锁可以升级不可以降级,但是偏向锁状态可以被重置为无锁状态。


几种锁的优缺点

在这里插入图片描述

锁升级详情可看:synchronized 锁的升级


有了synchronized为什么还需要volatile?

1、synchronized有性能损耗

synchronized是一种加锁机制。在JDK 1.6中对synchronized做了很多优化,如如适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等,但是他毕竟还是一种锁。加锁、解锁的过程是要有性能损耗的。

volatile变量的读操作的性能跟普通变量相比几乎无差别,但是写操作由于需要插入内存屏障所以会慢一些,即便如此,volatile在大多数场景下也比锁的开销要低。

2、synchronized会产生阻塞

synchronize本质上是一种阻塞锁,也就是说多个线程要排队访问同一个共享对象。

volatile是Java虚拟机提供的一种轻量级同步机制,无需阻塞便能保证共享对象的可见性。

3、synchronized无法禁止指令重排

synchronized是无法禁止指令重排和处理器优化的。

synchronized不是保证有序性吗,为什么会无法禁止指令重排?因为synchronized只能保证修饰部分代码与其前后的有序性,然代码块内部还是会发生重排序。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值