抱歉,您的浏览器无法访问本站

本页面需要浏览器支持(启用)JavaScript


了解详情 >

Java的synchronized

synchronized是Java提供的关键字,是Java并发编程中提供的同步器之一,可以对临界资源上锁。

锁的分类

根据获取的锁分类 : 获取对象锁获取类锁

获取对象锁的两种用法

  • 同步代码块(synchronized(this), synchronized(类实例对象)), 锁是小括号 () 中实例对象.
  • 同步非静态方法(synchronized method), 锁是当前对象的实例.

获取类锁(每个类只有一个类锁)

  • 同步代码块(synchronized(类.class)), 锁是小括号 () 中的类对象(Class对象)
  • 同步静态方法(synchronized static method), 锁是当前对象的类对象(Class对象)

对象锁类锁 总结

  1. 有线程访问对象的同步代码块时, 另外的线程可以访问该对象非同步代码块
  2. 若锁住的是同一个对象, 一个线程在访问对象的同步代码块/同步方法时, 另一个线程访问对象的同步代码块/同步方法的线程会被阻塞
  3. 若锁住的是同一个对象, 一个线程在访问对象的同步代码块时, 另一个访问对象的同步方法的线程会被阻塞, 反之亦然
  4. 同一个类的不同对象对象锁互不干扰
  5. 类锁由于也是一种特殊的对象锁, 因此表现和上诉1, 2, 3, 4一致, 而由于同一个类只有一把类锁, 所以同一个类的不同对象使用类锁将会同步的
  6. 类锁对象锁 互不干扰

synchronized 底层实现

每个对象都关联一个Monitor, 叫做内部锁. 低层通过 CPP 实现的, 源码文件 objectMonitor.cpp. Monitor 可以在对象创建销毁或当线程获取对象锁时, 自动生成.

解决了每个对象都可以上的问题(不用再定义锁变量了).

线程抢占锁, 就是抢占Monitor.

WaitSet, 等待池, Monitor 内部实现的等待集合, 就让多个线程先进行自旋竞争, 失败的在等待集合中休眠。

Monitor 锁的竞争, 获取与释放。

monitorentermonitorexit指令

synchronized (this) {
    System.out.println("Hello");
    // 再次获取锁对象, 这种情况叫做 重入
    synchronized (this) {
        System.out.println("World");
    }
}
3: monitorenter // 进入锁
4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc           #3                  // String Hello
9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: aload_0
13: dup
14: astore_2
15: monitorenter // 进入锁
16: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
19: ldc           #5                  // String World
21: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
24: aload_2
25: monitorexit // 解锁
26: goto          34
29: astore_3
30: aload_2
31: monitorexit // 解锁
39: astore        4
41: aload_1
42: monitorexit // 这个指令是 编译器 生成的, 在 异常出现后会调用
43: aload         4
45: athrow
46: return
// ...
// 同步方法 方法级别的同步是隐式的 ACC_SYNCHRONIZED 标志这个方法是否是同步的
public synchronized void syncTask();
  descriptor: ()V
  flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
  Code:
    stack=2, locals=1, args_size=1
        0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
        3: ldc           #6                  // String Hello Again
        5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        8: return
    LineNumberTable:
      line 18: 0
      line 19: 8
    LocalVariableTable:
      Start  Length  Slot  Name   Signature
          0       9     0  this   Lorg/lgq/interview/thread/SyncBlockAndMethod;

monitorenter: 获得锁, 进入临界区; 自旋失败后 -> 休眠 ->WaitSet.

monitorleave: 唤醒, 执行线程将释放锁.

重入现象

从互斥锁的设计上来说, 当一个线程试图操作一个由其它线程持有的对象锁的临界资源时, 将会处于阻塞状态, 但当一个线程再次请求自己持有对象的临界资源时, 不需要再次获得所, 这种情况属于重入.

synchronized 的不足之处

  • Java 早期版本中, synchronized 属于重量级锁, 依赖 Mutext Lock 实现
  • 线程之间的切换需要从用户态转换到内核态, 开销较大.

synchronized 的改进

Java 6 之后, synchronized 性能得到了很大的提升, 锁升级机制 和 一些优化操作.

  • Adaptive Spinning(自适应自旋)
  • Lock Eliminate(锁消除)
  • Lock Coarsening(锁粗化)
  • Lightweight Locking(轻量级锁)
  • Biased Locking(偏向锁)

自旋锁 和 自适应自旋锁

自旋锁

  • 在许多情况下, 共享数据的锁定状态持续时间较短, 切换线程不值得。
  • 通过让线程执行循环等待的释放, 不让出 CPU
  • 缺点 : 若锁被其它线程长时间占用, 会带来许多性能上的开销。

可以通过 PreBlcokPin 的参数来更改.

自适应自旋锁

  • 自旋的次数不再固定
  • 由前一次在同一个锁上的自旋时间以及锁的拥有者(Owner)的状态决定。

锁消除

更彻底的优化: JIT 编译时, 对运行上下文进行扫描, 去除不可能存在的竞争的锁。

public class StringBufferWithoutSync {
    public void add(String str1, String str2) {
        // StringBuffer是线程安全,
        // 由于sb只会在append方法中使用, 不可能被其他线程引用(没有 return 出去)
        // 因此sb属于不可能共享的资源, JVM会自动消除内部的锁
        StringBuffer sb = new StringBuffer();
        sb.append(str1).append(str2);
    }
    public static void main(String[] args) {
        StringBufferWithoutSync withoutSync = new StringBufferWithoutSync();
        for (int i = 0; i < 1000; i++) {
            withoutSync.add("aaa", "bbb");
        }
    }
}

锁粗化

另一个极端. 一般情况下, 在加同步锁的时候, 尽可能地将同步块的作用范围限制到尽量小的范围, 即只在共享数据的实际作用域中才进行同步, 这样是为了使同步的操作步骤尽可能的少. 但是如果存在一连串系列操作, 都对同一个对象反复加锁和解锁, 甚至加锁操作时出现在循环体中, 那么即使没有线程竞争, 频繁地进行互斥同步锁操作也会导致不必要的性能操作。

通过扩大加锁的范围, 避免反复加锁和解锁。

public class CoarseSync {
    public static String copyString100Times(String target) {
        int i = 0;
        StringBuffer sb = new StringBuffer();
        while (i < 100) {
            // JVM 会检查到这一连串的操作都对同一个对象进行同步锁的操作, 这时 JVM 就会加锁的同步范围 粗化 到一系列操作的外部.
            // 让这一连串的操作只需要加一次锁
            sb.append(target);
        }
        return sb.toString();
    }
}

线程间协作

读/计算/写 的模型

  1. Polling: while循环不断检查有没有新的读入. 然后进行处理.
  2. Queuing: 实现生产者/消费者模型.

Monitor需要考虑到线程间的协作, 因此Java语言为每个对象提供了wait方法用于休眠, 并提供了两个用于通知的方法: notifynotifyAll.

Monitor 提供, Object 代理(执行), Monitor 挂在 Object 上面.

synchronized 四种状态

轻量级锁偏向锁JDK6 后对 synchronized 的优化。

锁膨胀方向 : 无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁

偏向锁

减少同一线程重复获取锁的代价, 也就是只有一个线程进入临界区.

大多数情况下, 锁不存在多线程竞争, 总是由同一线程多次获得

核心思想 : 如果一个线程获得了锁, 那么锁就进入了偏向模式, JVM会将lockObject的对象头Mark Word的锁标志位设为”01”, 同时会用CAS操作Thread#1线程ID记录到Mark Word中, 当该线程再次请求锁时, 无需再做CAS操作, 即获取锁只需要检查Mark Word锁标记(101)是否为偏向锁以及当前线程Id是否等于Mark WordThreadID即可, 这样可以节省大量有关锁申请的操作. 以后该线程在进入和退出同步块时, 无需做 CAS 的操作来加锁和解锁, 从而提高性能。

不适用于锁竞争比较激烈的多线程场合。

轻量级锁

多个线程交替进入临界区。

适用情况 : 多个线程交替执行同步块。

轻量级锁 加锁过程

  1. 在线程进入同步块的时候, 如果同步对象锁状态为无锁状态(锁标志位为”01”状态), JVM 首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间, 用于存储对象目前的Mark Word的拷贝, 官方称之为Displaced Mark Word(加个前缀). 这时候线程堆栈对象头的状态如下
    线程堆栈与对象头的状态1
  2. 拷贝对象头Mark Word锁记录中.
  3. 拷贝成功之后, JVM将使用 CAS 操作 尝试将对象的Mark Wordptr_to_lock_record更新为指向Lock Record的指针, 并将Lock Record里的 owner 指针指向 object mark word.
  4. 如果这个更新动作成功了, 就代表这个线程就拥有了该对象的锁, 并且对象Mark Word的锁标志位为”00”, 即表示此对象处于轻量级锁状态, 这时候线程堆栈对象头的状态如下, 互相指向.
    线程堆栈与对象头的状态2
  5. 如果这个更新操作失败了, 就意味着至少存在一条线程与当前线程竞争该对象的锁. JVM 首先会检查对象的Mark Word是否指向当前线程堆栈, 如果是, 就说明当前线程已经拥有了这个对象锁, 那就可以直接进入同步块继续执行. 否则说明这个锁对象被其它线程抢占了.
  6. 如果出现两条以上的线程争桶一个锁的情况, 那轻量级锁就不再有效, 必须膨胀为重量级锁, 锁标志的状态值为”10”, 此时, Mark Word中存储的就是指向重量级锁(互斥量)的指针, 后面等待锁的线程也要进入阻塞状态.

轻量级锁 解锁过程

  1. 如果对象的Mark Word仍然指向线程的锁记录, 就通过 CAS 操作 尝试把线程中复制的 Displaced Mark Word 对象替换当前的 Mark Word.
  2. 如果替换成功, 整个同步过程就完成了.
  3. 如果替换失败, 说明有其它线程尝试获取锁, 那就要在释放锁的同时, 唤醒被挂起的线程.

锁的内存语义: 当线程释放锁时, Java内存模型会把该线程对应的本地内存中的变量刷新到主内存中; 而当线程获取锁时, Java内存模型就会把线程对应的本地内存置为无效, 从而使得被监视器保护的临界区代码必须从主内存中读取共享变量.

重量级锁

多个线程同时进入临界区.

若存在同一时间多个线程同时访问同一锁的情况, 就会导致轻量级锁膨胀为重量级锁, 会让其它线程阻塞.

若不膨胀为重量级锁,则所有等待轻量锁的线程只能自旋,可能会损失很多CPU时间.

偏向锁, 轻量级锁, 重量级锁 总结

优点 缺点 使用场景
偏向锁 加锁和解锁不需要CAS操作, 没有额外的性能消耗, 和执行非同步方法相比仅存在纳秒级的差距 如果线程间存在锁的竞争, 会带来额外的锁撤销的消耗 只有一个线程访问同步块或者同步方法的场景
轻量级锁 竞争的线程不会阻塞, 提高了响应速度 若线程长时间抢不到锁, 自旋会消耗CPU性能 线程交替执行同步或者同步方法的场景
重量级锁 线程竞争不适用自旋, 不会消耗CPU 线程阻塞, 响应时间缓慢, 在多线程下, 频繁地获取释放锁, 会带来巨大的性能消耗 追求吞吐量, 同步块或者同步方法执行时间较长的场景

synchronized 与 ReentrantLock

ReentrantLock – 重入锁,是基于AQS实现的同步器。

  • 位于java.util.concurrnet.locks包.
  • CountDownLatch, FutureTask, Semaphore 一样基于 AQS 实现.
  • 能够实现比 synchronized细粒度的控制, 如控制 fairness(不公平), 可以跨语句块.
  • 调用lock()之后, 必须调用unlock()释放锁.
  • 性能未必比 synchronizd 高, 并且也是可重入的.
  • ReentrantLock 提供中断能力. synchronized无法被打断.

ReentrantLock 公平性的设置.

  • ReentrantLock fairLock = new ReentrantLock(true); 设置为公平锁.
  • 公平锁, 倾向于将赋予等待时间最久的线程.
  • 获取锁的顺序按先后调用 lock 方法的顺序(慎用).
  • 非公平锁, 抢占的顺序不一定, 看运气.
  • synchronized 是非公平锁.

ReentrantLock锁对象化.

  • 判断是否有线程, 或者某个特定线程, 在排队等待获取锁.
  • 带超时的获取锁的尝试.
  • 感知有没有成功获取到锁.

是否能将 wait/notify/notifyAll 对象化

可以. java.util.concurrent.locks.Condition 可以做到. ArrayBlockingQueue是个例子.

总结

  • synchronized 是关键字, ReentrantLock 是类.
  • ReentrantLock 可对获取锁的等待时间进行设置(超时), 避免死锁.
  • ReentrantLock 可以获取各种锁的信息.
  • ReentrantLock 可以灵活实现多路通知.
  • 机制: synchronized 操作 Mark Word, ReentrantLock 调用 Unsafe 类的 park() 方法.

评论