并发问题最直接的处理方式-synchronized

738 阅读8分钟

在Java中按照加锁方式可以将同步锁分为两种类型。

微信图片_20210719211916.png

  1. 显式锁:ReentrantLock,实现juc里Lock,实现是基于AQS实现,需要手动加锁跟解锁ReentrantLock lock(),unlock();
  2. 隐式锁:Synchronized加锁机制JVM内置锁,不需要手动加锁与解锁,JVM会自动加锁跟解锁。

synchronized使用方式

  1. 指定加锁对象:对给定对象加锁,进入同步代码前要获得给定对象的锁
  2. 直接作用于实例方法:相当于对当前实例加锁,进入同步代码前要获得当前实例的锁。
  3. 直接作用于静态方法:相当于对当前类加锁,进入同步代码前要获得当前类的锁。
示例1synchronized (锁对象) {
    	// 受保护的资源
    }
示例2public synchronized void method() {
        // 受保护的资源
    }
	// 也可以理解为
    public synchronized(this) void method() {
        // 受保护的资源
    }
示例3public static synchronized void method() {
        // 受保护的资源
    }
    // 也可以理解为
    public synchronized(X.class) void method() {
        // 受保护的资源
    }

使用synchronized修饰的方法或者代码块,前后自动加上加锁lock()和解锁unlock()操作。

简易锁-c5a7884a283045589076fb2559fe3ddb.png

synchronized与原子性

增加同步代码块后,保证同一时间只有一个线程操作i++,以此来保证此操作的原子性。 线程1在执行monitorenter指令的时候,会对Monitor进行加锁,加锁后其他线程无法获得锁,除非线程1主动解锁。即使在执行过程中,由于某种原因,比如CPU时间片用完,线程1放弃了CPU,但是,他并没有进行解锁。而由于synchronized的锁是可重入的,下一个时间片还是只能被他自己获取到,还是会继续执行代码。直到所有代码执行完。这就保证了原子性。

synchronized与可见性

对一个变量解锁之前,必须先把此变量同步回主存中。这样解锁后,后续线程就可以访问到被修改后的值。

synchronized与有序性

就有序性而言,由于synchronized限制每次只有一个线程可以访问同步块,因此无论同步块内的代码如何乱序执行,只要保证串行语义一直,那么执行结果总是一样的。

synchronized底层原理

synchronized通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现。

public class SynchronizedTest {

    public static void main(String[] args) {

        // 对SynchronizedTest Class对象加锁
        synchronized (SynchronizedTest.class) {
            // ...
        }
    }
}

// Compiled from "SynchronizedTest.java"
public class com.miracle.lock.SynchronizedTest {
  public com.miracle.lock.SynchronizedTest();
    Code:
       0: aload_0       
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return        

  public static void main(java.lang.String[]);
    Code:
       0: ldc           #2                  // class com/miracle/lock/SynchronizedTest
       2: dup           
       3: astore_1      
       4: monitorenter  
       5: aload_1       
       6: monitorexit   
       7: goto          15
      10: astore_2      
      11: aload_1       
      12: monitorexit   
      13: aload_2       
      14: athrow        
      15: return        
    Exception table:
       from    to  target type
           5     7    10   any
          10    13    10   any
}

上面的class信息中,对于同步块的实现使用了monitorenter 和 monitorexit指令。其本质是对一个对象的监视器(monitor)进行获取,而这个获取过程是排他的,也就是同一时刻只能有一个线程取到synchronized多保护 对象的监视器。

任何一个对象都有自己的监视器,当这个对象由同步块后者这个对象的同步方法调用时,执行方法的线程必须取到该对象的监视器 才能进入同步块或者同步方法,而没有获取到的监视器的线程将会阻塞在同步块和同步方法 的入口处,进入BLOCKED状态。

synchronized原理.png

对象存储布局

  1. 对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象)等;

  2. 对象实际数据:即创建对象时,对象中成员变量,方法等;

  3. 对齐填充:对象的大小必须是8字节的整数倍。

对象的存储布局.png

在这里,我们可以通过一个工具类JOL来进行查看,引入其pom文件:

        <dependency>
            <groupId>org.openjdk.jol</groupId>
            <artifactId>jol-core</artifactId>
            <version>0.9</version>
        </dependency>
    public static void main(String[] args) {

        Object o = new Object();
        System.out.println(ClassLayout.parseInstance(o).toPrintable());

        synchronized (o) {
            System.out.println(ClassLayout.parseInstance(o).toPrintable());
        }
    }

// 运行结果
java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           21 00 00 00 (00100001 00000000 00000000 00000000) (33) // 1
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           c8 f0 3a 02 (11001000 11110000 00111010 00000010) (37417160) // 2
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

对比一下可以发现注释1和2位置对象头发生了改变,那么我们可以得到一个简单的结论:所谓的锁定对象指的就是修改对象的header,记录锁信息。

附图一张,供参考:

对象内存布局.png

synchronized性能优化

synchronized本质上是悲观锁,所以很多人就认为它的性能一定就比乐观锁差。其实这样说是不对的。能用synchronized解决问题的优先使用synchronized。这是因为在jdk1.5之后synchronized进行了优化,内部有锁升级的过程,偏向锁->自旋锁->重量级锁。

偏向锁概念

通过生产实践统计了解到锁常常不存在多线程竞争,而是总是由一个线程多次获得。为了让线程获取锁的代价更低就引入了偏向锁的概念。偏向锁就是当一个线程进入同步代码块时,会将线程ID存储在对象头中,而不需要再对此线程进行加锁和解锁操作。

使用-XX:-UseBiasedLocking参数来开启和关闭偏向锁优化。(默认开启)

偏向锁的获取和撤销
  1. 访问1访问同步代码块,判断当前锁状态,如果是无锁状态,判断是否处于可偏向状态;
  2. 如果是可偏向状态,则CAS替换Mark Word线程ID;
  3. 如果是已偏向状态,就检查Mark Word中的线程ID是否为当前线程,如果不是则CAS替换Mark Word为当前线程ID;
  4. 成功替换Mark Word则开启偏向锁,否则进行偏向锁撤销并暂停原来持有的偏向锁线程。等待原持有偏向锁到达安全点,如果此时原偏向锁线程未处于活动状态或者是已退出同步代码块,则唤醒原持有偏向锁的线程,如果原偏向锁线程处于同步代码块,则将偏向锁升级为轻量级锁。

无锁状态-_偏向锁-_撤销偏向锁.png

轻量级锁

轻量级锁使用的是自旋锁,即另外一个线程竞争锁资源时,当前线程会在原地循环等待,自旋锁是非常消耗CPU的,所以轻量级锁适用于那些同步代码块执行很快的场景,这样,线程原地等待时间很短就能够获得锁。

在JDK1.6之后,引入了自适应自旋锁,指的是自旋的次数并不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。在自旋超过N次后仍然没有获取到锁,则升级为重量级锁

轻量级解锁时,会使用CAS 操作来将 Mark Word 替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁

轻量级锁及膨胀过程.png

错误的加锁

使用不同的锁保护同一个资源

	private static int i = 0;
    public synchronized int get() {
        return i;
    }
    public static synchronized void add() {
        i += 1;
    }

使用多把锁保护资源.png

因此这两个临界区(方法)并没有互斥性,线程A和线程B还是可以并行执行,那add()操作对get()操作也是不可见的,所以还是会导致并发问题。

使用可变对象加锁

public class WrongLockDemo implements Runnable {
    public static Integer i = 0;
    
    @Override
    public void run() {
        for (int j = 0; j < 100000; j++) {
            synchronized (i) {
                i++;
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new WrongLockDemo());
        Thread t2 = new Thread(new WrongLockDemo());
        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println(i);
    }
}

发编译.png

反编译这段代码的run()方法,在执行i++时,真实执行的是Integer.valueOf(i.intValue()+1),查看valueOf源码:

    public static Integer valueOf(int i) {
        if (i >= IntegerCache.low && i <= IntegerCache.high)
            return IntegerCache.cache[i + (-IntegerCache.low)];
        return new Integer(i);
    }

默认Integer将-128到127提前实例化放到缓存中,但是超出时更倾向于创建一个Integer实例并将它的引用赋值给i。因此,每次获取到的锁都是不同的Integer对象,从而导致并发问题。

稿定设计-1.jpg