并发编程的重点也是难点是数据同步、线程安全、锁。要编写线程安全的代码,其核心在于对共享和可变的状态的访问进行管理。
共享意味着变量可以由多个线程访问,而可变则意味着变量的值在其生命周期内可以发生变化。
当多个线程访问某个状态变量且其中有一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。
Java中的主要同步机制是关键字synchronized,它提供了一种独占的加锁方式。
勾勾从一下几个方面来学习synchronized:
关键字synchronized的特性
synchronized关键字可以实现一个简单的策略来防止线程干扰和内存一致性错误,如果一个对象对多个线程是可见的,那么该对象的所有读和写都需通过同步的方式。
synchronized的特性:
不可中断:synchronized关键字提供了独占的加锁方式,一旦一个线程持有了锁对象,其他线程将进入阻塞状态或者等待状态,直到前一个线程释放锁,中间过程不可中断。
原子性: synchronized关键字的不可中断性保证了它的原子性。
可见性:synchronized关键字包含了两个JVM指令:monitor enter和monitor exit,它能够保证在任何时候任何线程执行到monitor enter时都必须从主内存中获取数据,而不是从线程工作内存获取数据,在monitor exit之后,工作内存被更新后的值必须存入主内存,从而保证了数据可见性。
有序性:synchronized关键字修改的同步方法是串行执行的,但其所修饰的代码块中的指令顺序还是会发生改变的,这种改变遵守java happens-before规则。
可重入性:如果一个拥有锁持有权的线程再次获取锁,则monitor的计数器会累加1,当线程释放锁的时候也会减1,直到计数器为0表示线程释放了锁的持有权,在计数器不为0之前,其他线程都处于阻塞状态。
关键字synchronized的用法
synchronized关键字锁的是对象,修饰的可以是代码块和方法,但是不能修饰class对象以及变量。
代码块,锁对象即是object
- private final Object obj = new Object();
- public void sync(){
- synchronized (obj){
-
- }
- }
方法,锁对象即是this
- public synchronized void syncMethod(){
-
- }
静态方法,锁对象既是class
- public synchronized static void syncStaticMethod(){
-
- }
勾勾在开发中最常用的是用synchronized关键字修饰对象,可以控制锁的粒度,所以针对最常用的场景勾勾去了解了它的字节码文件,先来看看勾勾的测试用例:
- public class TestSynchronized {
- private int index;
- private final static int MAX = 100;
- public void sync(){
- synchronized (new Object()){
- while (index < MAX){
- index ++;
- }
- }
- }
- }
运行命令 “javac -encoding UTF-8 TestSynchronized.java”编辑成class文件,然后
运行命令“javap -c TestSynchronized.class”得到字节码文件:
- public com.example.demo.articles.thread.TestSynchronized();
- Code:
- 0: aload_0
- 1: invokespecial #1 // Method java/lang/Object."
" :()V - 4: return
-
- public void sync();
- Code:
- 0: new #2 // class java/lang/Object
- 3: dup
- 4: invokespecial #1 // Method java/lang/Object."
" :()V - 7: dup
- 8: astore_1
- 9: monitorenter //进入同步代码块
- 10: aload_0 //加载数据
- 11: getfield #3 // Field index:I
- 14: bipush 100
- 16: if_icmpge 32
- 19: aload_0
- 20: dup
- 21: getfield #3 // Field index:I
- 24: iconst_1
- 25: iadd // 加1操作
- 26: putfield #3 // Field index:I
- 29: goto 10 //跳转至10行
- 32: aload_1
- 33: monitorexit // 退出同步代码块
- 34: goto 42 //跳转至42行
- 37: astore_2 // 刷新数据
- 38: aload_1
- 39: monitorexit
- 40: aload_2
- 41: athrow
- 42: return
- Exception table:
- from to target type
- 10 34 37 any
- 37 40 37 any
monitorenter和monitorexit是成对出现的,有时候你看到的是一个monitorenter对应多个monitorexit,但是能肯定的一定点是每一个monitorexit之前必有一个monitorenter。
从字节码文件中可以看到monitorenter之后执行了aload操作,monitorexit之后执行了astore操作。
TIPS:在使用synchronized关键字时注意事项
- 锁的对象不能为空;
- 锁的范围不宜太大;
- 不要试图使用不同的monitor来锁同一个方法;
- 避免多个锁交叉等待导致死锁;
锁膨胀
在jdk1.6之前,线程在获取锁时,如果锁对象已经被其他线程持有,此线程将挂起进入阻塞状态,唤醒阻塞线程的过程涉及到了用户态和内核态的切换,性能损耗比较大。
synchronized作为亲儿子,混的太差肯定不行,在jdk1.6对其进行了优化,将锁状态分为了无锁状态,偏向锁,轻量级锁,重量级锁。
锁的升级过程既是:
在了解锁的升级过程之前,勾勾重点理解了monitor和对象头。
在第一次研究锁膨胀的时候因为没有花时间去理解这两个概念,勾勾对锁升级的记忆只持续了3天,最后勾勾又用了两天的时间去学习对象头和monitor,才算是真正的理解锁的膨胀原理。所以大家在学习一个知识的时候,不要靠背去记忆一个知识点,一定要知其然。
每一个对象都与一个monitor相关联,monitor对象与实例对象一同创建并销毁,monitor是C++支持的一个监视器。锁对象的争夺既是争夺monitor的持有权。
勾勾在OpenJdk源码中找到了ObjectMonitor的源码:
- // initialize the monitor, exception the semaphore, all other fields
- // are simple integers or pointers
- ObjectMonitor() {
- _header = NULL;
- _count = 0;
- _waiters = 0,
- _recursions = 0;
- _object = NULL;
- _owner = NULL;
- _WaitSet = NULL;
- _WaitSetLock = 0 ;
- _Responsible = NULL ;
- _succ = NULL ;
- _cxq = NULL ;
- FreeNext = NULL ;
- _EntryList = NULL ;
- _SpinFreq = 0 ;
- _SpinClock = 0 ;
- OwnerIsThread = 0 ;
- }
- protected: // protected for jvmtiRawMonitor
- void * volatile _owner; // pointer to owning thread OR BasicLock
- volatile intptr_t _recursions; // recursion count, 0 for first entry
- private:
- int OwnerIsThread ; // _owner is (Thread *) vs SP/BasicLock
- ObjectWaiter * volatile _cxq ; // LL of recently-arrived threads blocked on entry.
- // The list is actually composed of WaitNodes, acting
- // as proxies for Threads.
- protected:
- ObjectWaiter * volatile _EntryList ; // Threads blocked on entry or reentry.
- private:
- Thread * volatile _succ ; // Heir presumptive thread - used for futile wakeup throttling
- Thread * volatile _Responsible ;
- int _PromptDrain ; // rqst to drain cxq into EntryList ASAP
- }
owner:指向线程的指针。即锁对象关联的monitor中的owner指向了哪个线程表示此线程持有了锁对象。
waitSet:进入阻塞等待的线程队列。当线程调用wait方法之后,就会进入waitset队列,可以等待其他线程唤醒。
entryList:当多个线程进入同步代码块之后,处于阻塞状态的线程就会被放入entryList中。
那什么是对象头呢,它与synchronized又有什么关系呢?
在JVM中,对象在内存中分为3块区域:
- 对象头Mark Word(标记字段):用于存储对象的hashcode,分代年龄,锁标志位,是否可偏向标志,在运行期间,其存储的数据会发生变化。Klass Point(类型指针):该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位。
- 实例数据用于存放类的数据信息
- 填充数据虚拟机要求对象起始地址必须是8字节的整数倍,当不满足时需对其填充。
我们先通过一张图了解下在锁升级的过程中对象头的变化:
接下来我们分析锁升级的过程:
第一个分支锁标志为01:
当线程运行到同步代码块时,首先会判断锁标志位,如果锁标志位为01,则继续判断偏向标志。
如果偏向标志为0,则表示锁对象未被其他线程持有,可以获取锁。此时当前线程通过CAS的方法修改线程ID,如果修改成功,此时锁升级为偏向锁。
如果偏向标志为1,则表示锁对象已经被占有。
进一步判断线程id是否相等,相等则表示当前线程持有的锁对象,可以重入。
如果线程id不相等,则表示锁被其他线程占有。
需进一步判断持有偏向锁的线程的活动状态,如果原持有偏向锁线程已经不活动或者已经退出同步代码块,则表示原持有偏向锁的线程可以释放偏向锁。释放后偏向锁回到无锁状态,线程再次尝试获取锁。主要是因为偏向锁不会主动释放,只有其他线程竞争偏向锁的时候才会释放。
如果原持有偏向锁的线程没有退出同步代码块,则锁升级为轻量级锁。
偏向锁的流程图如下:
第二个分支锁标志为00:
在第一个分支中我们了解到在如果偏向锁已经被其他线程占有,则锁会被升级为轻量级锁。
此时原持有偏向锁的线程的栈帧中分配锁记录Lock Record,将对象头中的Mark Word信息拷贝到锁记录中,Mark Word的指针指向了原持有偏向锁线程中的锁记录,此时原持有偏向锁的线程获取轻量级锁,继续执行同步块代码。
如果线程在运行同步块时发现锁的标志位为00,则在当前线程的栈帧中分配锁记录,拷贝对象头中的Mark Word到锁记录中。通过CAS操作将Mark Word中的指针指向自己的锁记录,如果成功,则当前线程获取轻量锁。
如果修改失败,则进入自旋,不断通过CAS的方式修改Mark Word中的指针指向自己的锁记录。
当自旋超过一定次数(默认10次),则升级为重量锁。
轻量锁的锁是主动释放的,持有轻量锁的线程在执行完同步代码块后,会先判断Mark Word中的指针是否依然指向自己,且自己锁记录中的Mark Word信息与锁对象的Mark Word信息一致,如果都一致,则释放锁成功。
如果不一致,则锁有可能已经被升级为重量锁。
轻量级流程图如下图:
第三个分支锁标志位为10:
锁标志为10时,此时锁已经为重量锁,线程会先判断monitor中的owner指针指向是否为自己,是则获取重量锁,不是则会挂起。
整个锁升级过程中的流程图如下,如果看懂了一定要自己画一遍。
总结:
synchronized关键字是一种独占的加锁方式,不可中断,保证了原子性,可见性,和有序性。
synchronized关键字可用于修饰方法和代码块,不能用于修饰变量和类。
多线程在执行同步代码块时获取锁的过程在不同的锁状态下不一样,偏向锁是修改Mark Word中的线程ID,轻量锁是修改Mark Word的指针指向自己的锁记录,重量锁是修改monitor中的指针指向自己。
今天就学到这里了!收工!
并发编程、JVM、数据结构基础知识更新完了,后续还会慢慢补充!