线程安全性的原理分析之synchronized
多线程对于共享变量访问带来的安全性问题
线程的合理使用能够提升程序的处理性能,因为多线程可以利用多核CPU以及超线程技术来实现线程的并行执行,而且线程的异步化执行相比于同步执行,异步执行能够很好的优化程序的处理性能,提升并发的吞吐量;同时也带来了很多的问题。先看一个栗子:
public class App {
public static int count = 0;
public static void incr(){
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
}
public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
new Thread(() -> App.incr()).start();
}
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("运行结果:"+count);
}
}
--------------------------------------------------------------------
运行结果小于等于1000
上边的例子我们用1000个线程对count这个共享变量去做++的操作,结果却存在小于1000的数,而且概率还挺大的,这就出现了数据的安全性问题。原因如下图:
线程A和B同时拿到共享变量并对共享变量做++的操作,线程A操作完之后将值赋给count,线程B操作完后也一样的把值赋给count这样最终的结果count就相当于是执行了一次
synchronized
synchronized最一开始时实施的是重量级锁,在JAVA SE 1.6为了减少获得索和释放所带来的性能消化引入了偏向锁和轻量级锁
基本语法
synchronized的使用方式:
1、修饰实例方法,作用于当前实例枷锁,进入同步代码前需要先获得实例锁
public class SynchronizedTest{
//修饰未被static修饰的方法
public synchronized void test1(){}
public static void main(String[] args){
//只能作用同一个实例对象,使用两个实例对象就超出了锁的范围
SynchronizedTest test1 = new SynchronizedTest();
test1.test1();
//在创建test2对象时synchronized锁就失效了
SynchronizedTest test2 = new SynchronizedTest();
test2.test1();
}
}
2、静态方法,作用于当前类对象枷锁,进入同步代码钱要获得当前类对象的锁
public class SynchronizedTest{
public synchronized static test1(){}
public static void main(String[] args){
SynchronizedTest.test1();
SynchronizedTest.test1();
}
}
3、修饰代码块,指定加锁对象,进入同步代码钱要获得给定对象的锁
//该方式有两种形式,1.实例对象,2.类对象,两者的区别在于是否被static修饰
public class SynchronizedTest{
static int i = 0;
//实例对象锁
Object obj1 = new Object();
//类对象锁
static Object obj2 = new Object();
public static void test1(){
synchronized(obj){
i++;
}
}
public static void test2(){
synchronized(this){}//等价于实例对象锁
synchronized(SynchronizedTest.class){}//等价于类对象锁
}
应用
解决上面存在数据安全的案例
public class App {
public static int count = 0;
public static void incr(){
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized(App.class){
count++;
}
}
public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
new Thread(() -> App.incr()).start();
}
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("运行结果:"+count);
}
原理解析
synchronized使得多线程具有互斥的特性,锁是至关重要的,只有获得锁的线程才有访问共享资源的资格,那么线程是如何获得锁的呢?获得锁的条件又是什么?下边进行讲解
锁的存储
在Java中锁是用对象来实现的,在Hotspot虚拟机中,对象在内存中的存储布局可以分为三个区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding),如下图:
JVM源码实现:
在Java代码中,使用new创建一个对象实例时,JVM层面就会创建一个instanceOopDesc对象,hotspot源码如下(在instanceOop文件):
//一个instanceOop代表一个Java类,当new一个Java类的时候就会创建一个instanceOop
// An instanceOop is an instance of a Java Class
// Evaluating "new HashTable()" will create an instanceOop.
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);
}
观察源码可以看到instanceOopDesc派生自oopDesc,oopDesc源码如下(在oop.hpp文件)
class oopDesc {
friend class VMStructs;
private:
volatile markOop _mark;//对象头
union _metadata {//源数据
Klass* _klass;//普通指针
narrowKlass _compressed_klass;//压缩指针
} _metadata;
在oopDesc中可以看到关于对象的描述,我们重点关注对象头markOop;找到markOop源码(在markOop文件)
// 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)
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//偏向锁的时间戳
};
观察markOop源码中的enum和注释中都可以看到对象头的分代年龄,锁标识,是否为偏向锁等定义;我们重点关注对象头部分,对象头表示图如下:
在上面看了JVM的源码知识为了弄清楚Java对象头中都存储了什么东西,接下来我们使用一个工具去看一下我们创建Java对象的内存布局
在maven项目的pom文件中引入下边工具包
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
编写代码如下:
public class ClassLayoutDemo {
public static void main(String[] args) {
//没有加锁
ClassLayoutDemo demo = new ClassLayoutDemo();
System.out.println(ClassLayout.parseInstance(demo).toPrintable());
}
}
运行之后的结果如下:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 05 c1 00 20 (00000101 11000001 00000000 00100000) (536920325)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
运行结果中的OFFSET表示偏移量,SIZE表示占用的内存大小,TYPE DESCRIPTION表示类型(对象头),VALUE就是具体的值
上边的结果是无锁对象的对象头,下边结合前边对象头表示图对这个VALUE进行解读,在对象头表示图中可以看到在最后两位存储的是锁标识位,倒数第三位存储的是是否偏向锁,我们只需要看三位就可以了,然后去解读工具打印的结果,在这个结果中我们主需要关注以下就可以了
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
这里将结果从后往前进行重排序的到最终的结果00 00 00 00 00 00 00 01(16进制的,2进制的结果就是后边括号里面的)
(这里涉及到汇编的大端存储和小端存储,这里不展开,有兴趣可以从网上搜一下),查看最后三位是001
然后再看对象头表示图中的无锁栏进行对比证明无锁对象的存储;(有兴趣的朋友可以自己去运行后查看有锁的对象的存储)
锁的升级
在jdk1.6的时候对synchronized做了优化加入了偏向锁和轻量级锁的概念。当线程A访问被同步锁保护的共享资源时会先去获取偏向锁(及在锁对象头中的锁标识位的值为01
,是否偏向锁位为1
,并且将获得偏向锁的线程ID存到对象头中);获取偏向锁不成功升级去获取轻量级锁,获取不到轻量级锁进入重量级锁也就是阻塞。
偏向锁的基本原理
当一个线程访问加了同步锁的共享资源时,会在对象头中存储当前线程的ID,后续这个线程进入和退出共享资源时,不需要再次加锁和释放锁。而是直接比较对象头里卖弄是否存储了指向当前线程的偏向锁。如果相等表示偏向锁是属于当前线程的,就不需要再次获得锁。
偏向锁获取逻辑
- 获取锁对象的Markword,判断是否处于可偏向状态。(biased_lock=1、且ThreadId为空)
- 如果是可偏向状态,通过CAS操作,把当前线程的ID写入到Markword
a. 如果CAS成功,那么当前线程就表示已经获得了锁对象的偏向锁,接着执行同步代码块
b、如果CAS失败,说明有其他线程已经获得了偏向锁,需要先撤销已经获得 偏向锁的线程,并且把持有的锁升级为轻量级锁(这个操作需要等到没有线程在执行字节码才能执行) - 如果是已偏向状态,需要检查MarkWord中存储的线程ID是否等于当前线程ID
a、如果相等,不需要再次获得锁,直接执行同步代码块
b、如果不相等,需要撤销偏向锁并升级到轻量级锁
偏向锁的撤销
偏向锁的撤销并不是把对象恢复到无锁可偏向状态,而是在获取偏向锁的过程中,发现CAS失败时,直接把被偏向的锁对象升级到被加了轻量级锁的状态。对原持有偏向锁的线程进行撤销时,原获得偏向锁的线程有两种情况:
- 原获得偏向锁的线程如果已经执行完毕就把对象头设置成无锁状态并且争抢锁的线程可以基于CAS重新偏向当前线程
- 如果原获得偏向锁的线程的同步代码块还没执行完就会把原获得偏向锁的线程升级为轻量级锁后继续执行同步代码块
轻量级锁的基本原理
轻量级锁加锁逻辑
锁升级为轻量级锁之后,对象的MarkWord也会进行相应的变化
- 线程在自己的栈桢中创建锁记录lockrecord
- 将锁对象的对象头中的MarkWord复制到线程刚刚创建的锁记录中
- 将锁记录中的owner指针指向锁对象
- 将锁对象的对象头的MarkWord替换为指向锁记录的指针
轻量级锁解锁
轻量级锁的释放逻辑其实就是获得锁的逆向逻辑,通过CAS操作把线程栈桢中的lockrecord替换会到锁对象的MarkWord中,如果成功表示没有竞争,如果失败表示当前所存在竞争,那么所就会膨胀成为重量级锁
重量级锁的基本原理
当轻量级锁膨胀到重量级锁之后,线程只能被阻塞等待唤醒
重量级锁的monitor
每一个Java对象都会于一个监视器monitor关联,当一个线程想要执行一段被synchronized修饰的同步代码块时,该线程得先获取到对象对应的monitor。monitorenter表示去获得一个对象监视器。monitorexit表示释放monitor监视器的所有权,是的其他被阻塞的线程可以尝试去获得这个监视器monitor以来操作系统的MutexLock(互斥所)来实现的,线程被阻塞后边进入内核调度状态,这个会导致系统在用户状态与内核状态之间来回切换,严重影响锁的性能
重量级锁加锁的基本流程
任意线程对加锁的同步代码块的访问受限要获得锁对象的监视器,如果获取失败,线程进入同步队列,线程状态变为BLOCKED。当获得锁的线程释放了锁,则释放锁的操作会唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取