Java并发编程之synchronized锁原理

Java线程安全

线程的合理使用能够提升程序的处理性能,真正意义上实现并发执行任务,提升程序吞吐量,但是也会引发一些列线程安全问题,比如共享变量等待。对于线程安全性,本质上是管理对于数据状态的访问,而且这个状态通常是共享的、可变的。共享是指这个变量可以同时被多个线程访问了;可变,指这个变量的值在它的生命周期内是可以改变的。

一个对象线是否是线程安全的,取决于它是否会被多个线程同时访问,以及程序中是如何去使用这个对象的。如果多个线程访问同一个共享对象,在不需要额外的同步以及调用端代码不用做其他协调的情况下,这个共享对象的状态依然是正确的(正确性意味着这个对象的结果与预期规定的结果保持一致),那说明这个对象是线程安全的。

java中如何解决由于线程并行导致的数据安全性问题?

数据安全性问题的本质在于解决共享数据并发访问的问题,如果能使线程并行变成串行,那么就能解决这个问题。在java中,听到和接触到最多的就是锁的概念,比如悲观锁、乐观锁等等,它是处理并发的一种同步手段,如果要解决数据安全性问题,那么这个锁一定需要实现互斥的特性,java提供枷锁的方法就是synchronized关键字。

synchronized的基本认识

多线程并发编程中 synchronized 一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着 Java SE 1.6 对 synchronized 进行了各种优化之后,有些情况下它就并不那么重,Java SE 1.6 中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁。

synchronized的基本使用

synchronized的使用分为两种形式:一种是修饰代码块(比较灵活),一种是修饰方法;

两种作用范围:对象锁、类锁,区别是是否跨对象、跨线程被保护

  • 修饰实例方法(作用域为当前实例,对当前实例枷锁,进入代码前要先获得当前实例的锁)

    synchronized修饰实例方法和在代码块中修饰当前实例对象synchronized(this)是等价的,作用范围是当前实例对象

  • 修饰代码块(指定枷锁对象,对指定对象枷锁,进入同步代码前要先获得给定对象的锁)

  • 修饰静态方法(作用域为类对象,对当前类对象枷锁,进入同步代码前要获得当前类对象的锁)

    synchronized修饰静态方法和在代码块中修饰当前类对象synchronized(Class)是等价的,作用范围是全局的

代码示例

/**
 * 两种表现形式:修饰方法、修饰代码块
 * synchronized两种作用域:对象锁(只作用域当前实例)、类锁(全局,跨对象、跨线程)
 * 方法test1和test2是等价的(对象锁),方法test3和test4是等价的(类锁)
 */
public class SynchronizedTest {
    public synchronized void test1() {}

    public void test2() { synchronized (this) {} }

    public synchronized static void test3() {}

    public void test4() { synchronized (SynchronizedTest.class) {} }

    public static void main(String[] args) throws IOException {
        SynchronizedTest st1 = new SynchronizedTest();
        SynchronizedTest st2 = new SynchronizedTest();
      	// 不同实例调用同一个方法
      	// new Thread(()->st1.test1()).start();
        // new Thread(()->st2.test1()).start();
      	// 同一实例调用不同的方法
        // new Thread(()->st1.test1()).start();
        // new Thread(()->st1.test2()).start();
        // 类锁示例
        new Thread(()->st1.test4()).start();
        new Thread(()->st2.test4()).start();
        System.in.read();
    }
}
synchronized锁在内存中是如何存储的?

从语法上看,synchronized(lock)是基于lock这个对象的生命周期来控制锁的粒度,也就说作用域由对象的生命周期决定的(实例、类对象),那么锁与对象是什么样的关系?接下来去看下jvm源码,看下对象在内存中是如何布局的。

对象在内存中的布局

在Hotspot虚拟机中,对象在内存中的布局可以分为三个部分:对象头(Header)、实例数据(Instance Data)、对其填充(Padding),如下图所示:
在这里插入图片描述

jvm源码实现

我们在java代码中,使用new创建一个对象实例,JVM实际上会创建一个instanceOopDesc对象。

Hotspot虚拟机采用OOP-Klass模型来描述Java对象实例(OOP(Ordinary Object Point)指的是普通对象指针,Klass用来描述对象实例的具体类型),Hotspot虚拟机采用instanceOopDesc和arrayOopDesc来描述对象头(arrayOopDesc用来描述数组类型)

instanceOopDesc对应hotstpot虚拟机文件位置/src/share/vm/oops/instanceOop.hpp,
arrayOopDesc对应hotstpot虚拟机文件位置/src/share/vm/oops/arryOop.hpp,源码如下:

class instanceOopDesc : public oopDesc {
 public:
  // aligned header size.
  static int header_size() { return sizeof(instanceOopDesc)/HeapWordSize; }

  // If compressed, the offset of the fields of the instance may not be aligned.
  static int base_offset_in_bytes() {
    // offset computation code breaks if UseCompressedClassPointers
    // only is true
    return (UseCompressedOops && UseCompressedClassPointers) ?
             klass_gap_offset_in_bytes() :
             sizeof(instanceOopDesc);
  }

  static bool contains_field_offset(int offset, int nonstatic_field_size) {
    int base_in_bytes = base_offset_in_bytes();
    return (offset >= base_in_bytes &&
            (offset-base_in_bytes) < nonstatic_field_size * heapOopSize);
  }
};

从instanceOopDesc代码中可以看出,instanceOopDesc继承自oopDesc,oopDesc的定义在/src/share/vm/oops/oop.hpp中

class oopDesc {
  friend class VMStructs;
 private:
  volatile markOop  _mark;
  union _metadata {
    Klass*      _klass;
    narrowKlass _compressed_klass;
  } _metadata;

  // Fast access to barrier set.  Must be initialized.
  static BarrierSet* _bs;
  /** 省略下面的部分源码 */
}

从源码中可以看到,普通对象的实例中,oopDesc的定义包含两个成员,分别是_mark_metadata

  • _mark表示对象标记,属于markOop类型,也就是MarkWord,记录了对象和锁有关的信息
  • _metadata表示类元信息,存储的是对象指向他的类元数据(Klass)的首地址,其中_klass表示普通指针,_compressed_klass表示压缩类指针

markOop定义在/src/share/vm/oops/markOop.hpp文件中,源码如下:

// The markOop describes the header of an object.
//
// Note that the mark is not a real oop but just a word.
// It is placed in the oop hierarchy for historical reasons.
//
// Bit-format of an object header (most significant first, big endian layout below):
//
//  32 bits:
//  --------
//             hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)
//             JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)
//             size:32 ------------------------------------------>| (CMS free block)
//             PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
//
//  64 bits:
//  --------
//  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
//  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
//  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
//  size:64 ----------------------------------------------------->| (CMS free block)
//
//  unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)
//  JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)
//  narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
//  unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)
//
//  - hash contains the identity hash value: largest value is
//    31 bits, see os::random().  Also, 64-bit vm's require
//    a hash value no bigger than 32 bits because they will not
//    properly generate a mask larger than that: see library_call.cpp
//    and c1_CodePatterns_sparc.cpp.
//
//  - the biased lock pattern is used to bias a lock toward a given
//    thread. When this pattern is set in the low three bits, the lock
//    is either biased toward a given thread or "anonymously" biased,
//    indicating that it is possible for it to be biased. When the
//    lock is biased toward a given thread, locking and unlocking can
//    be performed by that thread without using atomic operations.
//    When a lock's bias is revoked, it reverts back to the normal
//    locking scheme described below.
//
//    Note that we are overloading the meaning of the "unlocked" state
//    of the header. Because we steal a bit from the age we can
//    guarantee that the bias pattern will never be seen for a truly
//    unlocked object.
//
//    Note also that the biased state contains the age bits normally
//    contained in the object header. Large increases in scavenge
//    times were seen when these bits were absent and an arbitrary age
//    assigned to all biased objects, because they tended to consume a
//    significant fraction of the eden semispaces and were not
//    promoted promptly, causing an increase in the amount of copying
//    performed. The runtime system aligns all JavaThread* pointers to
//    a very large value (currently 128 bytes (32bVM) or 256 bytes (64bVM))
//    to make room for the age bits & the epoch bits (used in support of
//    biased locking), and for the CMS "freeness" bit in the 64bVM (+COOPs).
//
//    [JavaThread* | epoch | age | 1 | 01]       lock is biased toward given thread
//    [0           | epoch | age | 1 | 01]       lock is anonymously biased
//
//  - the two lock bits are used to describe three states: locked/unlocked and monitor.
//
//    [ptr             | 00]  locked             ptr points to real header on stack
//    [header      | 0 | 01]  unlocked           regular object header
//    [ptr             | 10]  monitor            inflated lock (header is wapped out)
//    [ptr             | 11]  marked             used by markSweep to mark an object
//                                               not valid at any other time
//
//    We assume that stack/thread pointers have the lowest two bits cleared.
class markOopDesc: public oopDesc {
 private:
  // Conversion
  uintptr_t value() const { return (uintptr_t) this; }

 public:
  // Constants
  enum { age_bits                 = 4,   // 分代年龄
         lock_bits                = 2,  // 锁标志
         biased_lock_bits         = 1,  // 是否为偏向锁
         max_hash_bits            = BitsPerWord - age_bits - lock_bits - biased_lock_bits,
         hash_bits                = max_hash_bits > 31 ? 31 : max_hash_bits, // 对象的哈希值
         cms_bits                 = LP64_ONLY(1) NOT_LP64(0),
         epoch_bits               = 2  // 偏向锁的时间戳
  };
  /** 其他代码省略 */
}

MarkWord记录了对象和锁的有关信息,当一个对象被synchronized关键字修饰作为同步锁时,接下来围绕这个锁的一些列操作都和MarkWord有关系。MarkWord在32位虚拟机长度为32bit,在64位虚拟机长度为64bit。MarkWord里面存储的数据会随着锁标志位的变化而变化,变化情况分为以下5种:
在这里插入图片描述

JAVA中任何对象都可以实现锁
  1. java中的每个对象都派生自Object类,而Java Object在JVM内部都有一个native C++ 对象oop/oopDesc与之对应

  2. 线程在获取锁的时候,实际上就是获得一个监视器对象(monitor),monitor可以认为是一个同步对象,所有的Java对象天生携带monitor,在Hotspot源码的markOop.hpp文件中,可以看到下面的这段代码。

ObjectMonitor* monitor() const {
  assert(has_monitor(), "check");
  // Use xor instead of &~ to provide one extra tag-bit check.
  return (ObjectMonitor*) (value() ^ monitor_value);
}

多个线程访问代码快时,相当于去竞争对象监视器,修改对象中的锁标识,上面代码中ObjectMonitor这个对象和线程竞争锁的逻辑有密切的关系。

synchronized锁的升级

java1.6之后对Synchronized锁做了优化,优化的背景或产生偏向锁、轻量级锁的需求场景是什么?

前面分析了MarkWord的源码,提到了偏向锁、轻量级锁、重量级锁,既然使用锁能够实现线程的安全性,但同时也会带来性能的下降。如果不使用锁,虽然能提高并发性能,但是不能保证线程安全,两者之间不能同时满足要求。

Hotspot虚拟机编写的作者经过调查发现,大部分情况下,枷锁的代码不仅仅不存在多线程竞争,而且总是有同一个线程多次获得锁。基于这样一个概率,在jdk1.6之后,对锁做了一些优化,为了减少获得锁和释放锁所带来的性能开销,引入了偏向锁、轻量级锁的概念。所以synchronized锁在运行时存在四种状态:无锁、偏向锁、轻量级锁和重量级锁,锁的状态会根据线程竞争锁的激烈程度从低到高不断升级。(锁只能升级(或被撤销),不能降级)

偏向锁的基本原理

经过调查,大部分情况下,锁不紧不存在多线程竞争,而是总是有同一个线程多次获得,为了让线程获得锁的代价更低,就引入了偏向锁的概念。

点一个线程访问了加入同步锁的代码块,会在锁的对象头中存储当前线程的ID,后续这个线程进入和退出同步代码块时,不需要再次枷锁和释放锁,直接比较对象头中是否存储了指向当前线程的偏向锁,如果偏向锁存储的线程ID与当前线程ID相等,表示偏向锁是偏向于当前线程的,就不需要再次尝试获得锁。

偏向锁的获取和撤销逻辑
  1. 获取锁对象的MarkWord,判断是否处于偏向状态(biased_lock=1,并且ThreadId为空)

  2. 如果是可偏向状态,则通过CAS操作,把当前线程ID写入到MarkWord

    • 如果cas成功,标示获得了锁对象的偏向锁,接下来执行同步代码块
    • 如果cas失败,说明有其他线程已经获得了偏向锁,当前锁存在竞争,需要撤销已经获得锁的线程,并且把锁升级成为轻量级锁(这个操作需要等到全局安全点,也就是没有线程执行同步代码块,才能执行)
  3. 如果是已偏向的状态,需要检查MarkWord中存储的ThreaId是否等于当前线程的ID

    • 如果相等,不需要再次获得锁,可以直接执行同步代码块

    • 如果不相等,说明偏向锁偏向其他线程,需要撤销偏向锁并升级为轻量级锁

偏向锁的撤销

偏向锁的撤销并不是把锁对象恢复到无锁可偏向状态(偏向锁不存在锁释放的概念),而是在获取锁的过程中,发现cas失败,存在线程竞争时,直接把偏向锁升级到了轻量级锁。

对原持有偏向锁的线程进行撤销时,原获得锁的线程有两种情况:

  1. 原获得偏向锁的线程如果已经退出了临界区,也就是同步代码块执行完毕,这个时候会把对象头设置成无锁状态,争夺锁的线程可以基于CAS重新偏向于自己。

  2. 如果原获得偏向锁的线程还没有执行完同步代码块,处于临界区之内,这个时候会把偏向锁升级成为轻量级锁,原获得偏向锁的线程继续执行代码块

在实际应用开发中,绝大部分情况一定会存在2个以上的线程竞争锁,那么开启偏向锁反而会增加获得锁的资源消耗,可以通过jvm参数UserBiasedLocking来设置开启和关闭偏向锁
在这里插入图片描述

轻量级锁的基本原理
轻量级锁的加锁和解锁逻辑

锁升级为轻量级锁之后,锁对象的MarkWord也会进行相应的变化,升级为轻量级锁的过程如下:

  1. 线程在自己的栈针中创建锁记录LockRecord
  2. 将锁对象的对象头的MarkWord复制到线程创建的锁记录中
  3. 将锁记录中的owner指针指向锁对象
  4. 将锁对象的对象头的MarkWord替换为指向线程锁记录的指针
    在这里插入图片描述
    在这里插入图片描述
自旋锁

轻量级锁在加锁过程中使用到了自旋锁,所谓自旋锁就是指,当另外一个线程来竞争所是,这个线程会原地循环等待,而不是把线程阻塞。获得锁的线程释放锁之后,这个线程就可以立马获得锁(获得锁的过程在原地循环的时候,会消耗CPU资源,相当于在执行一个啥也没有的for循环)。所以,轻量级锁只用于同步代码块执行时间很短的场景,这样原地等待的线程能够很短的时间就能获得锁。

自旋锁的使用,也是有一定概率的背景,在大部分同步代码块执行时间很短的情况下,通过循环可以提高锁的性能。但是自旋必须要有一定的条件控制,否则会因为一个线程同步代码块执行很长时间,不断的循环导致消耗过多CPU资源。默认情况下自旋次数是10,可以通过JVM参数PreBlockSpin来修改。

在JDK1.6之后,引入了自适应自旋锁,自适应自旋锁意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。如果在同一个锁对象上,一个线程自旋等待成功获得锁,并且持有锁的线程正在运行中,那么虚拟机会认为这次自旋很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果某个锁通过自旋很少成功获得过,那么以后尝试获取这个锁可能省略掉自旋过程,直接阻塞,避免浪费处理器资源。

轻量级锁的解锁

轻量级锁的释放过程就是获得锁的逆向逻辑,通过CAS操作吧栈针中的LockRecord替换回到所对的MarkWord中,如果成功,表示没有竞争;如果失败,表示当前锁存在竞争,那么轻量级锁会升级为重量级锁。
在这里插入图片描述

重量级锁的基本原理

当轻量级锁膨胀到重量级锁之后,意味着线程只能被挂起阻塞,等待被唤醒。

重量级锁的monitor
public class App {
    public static void main(String[] args) {
        synchronized (App.class) {}
        test();
    }
    public static synchronized void test () {}
}

运行以后通过 java p工具查看生成的 class 文件信息分析 synchronized关键字的实细节 javap -v app. class,

加了同步代码块以后,在字节码文件中会看到一个monitorenter和monitorexit

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: ldc           #2                  // class com/seiya/concurrent/App
         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: invokestatic  #3                  // Method test:()V
        18: return

每一个Java对象都会与一个监视器monitor关联,我们可以把它理解成为一把锁,当一个线程想要执行一段被synchronized关键字修饰的代码块是,该线程先获得synchronized修饰的对象对应的monitor。monitorenter表示去获得一个对象监视器,monitorexit表示释放监视器的所有权,使得其他被阻塞的线程可以尝试去获得这个监视器。

monitor监视器依赖操作系统的MutexLock(互斥锁)来实现,线程被阻塞后,进入内核(Linux)调度状态,这个导致系统在用户态和内核态之间来回切换,严重影响锁的性能。

重量级锁加锁的基本流程

在这里插入图片描述
任意线程对Object(Object有synchronized保护)的访问,首先要获得Object的监视器。如果失败,线程进入同步队列,状态变成BLOCKED。当访问Object的的前驱(获得了锁的线程)释放了锁,则该释放操作唤醒阻塞在同步队列的线程,是其重新尝试对监视器的获取。

synchronized结合Java Object对象中的wait、notify、notifyall

阻塞线程什么时候被唤醒,取决于获得锁的线程什么时候执行完同步代码块并且释放锁。如果要想显示控制,需要借助一个信号机制:在Object对象中,提供了wait/notify/notifyall,可以用于控制线程的状态。

wai/notify/notifyall的基本概念
  • wait:表示持有对象锁的线程A准备释放锁权限,释放CPU资源并进入等待状态

  • notify:表示持有对象锁的线程A准备释放锁权限,通知jvm唤醒某个竞争该对象锁的线程X。线程A执行完同步代码并且释放了锁之后,线程X直接获得对象锁权限,其他竞争线程继续等待(即使线程X同步完毕,释放对象锁,其他竞争线程仍然继续等待,直至有新的notify或notifyall来唤醒)

  • notifyall:和notify不同的是,notifyall会唤醒所有竞争同一个对象锁的所有线程,当获得锁的线程A释放锁之后,所有被唤醒的线程都有可能获得对象锁权限

三个方法必须在synchronized同步关键字所限定的作用域内调用,否则会报错java.lang.IllegalMonitorStateException,意思就是没有同步,所以线程对对象锁的状态是不确定的,不能调用这些方法。另外,通过同步机制来确保线程从wait方法返回是能够感知到notify线程对变量做出的修改。

wait/notify 的基本原理

在这里插入图片描述
在这里插入图片描述

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值