volatile
作用:可见性和防止指令重排序
实现: 字节码:ACC_VOLATILE,
JVM: 内存屏障:loadloadbarrier/loadstorebarrier/storeloadbarrier/storestorebarrier
操作系统: lock指令
消除缓存行的伪共享
除了我们在代码中使用的同步锁和jvm自己内置的同步锁外,还有一种隐藏的锁就是缓存行,它也被称为性能杀手。 在多核cup的处理器中,每个cup都有自己独占的一级缓存、二级缓存,甚至还有一个共享的三级缓存,为了提高性能,cpu读写数据是以缓存行为最小单元读写的;32位的cpu缓存行为32字节,64位cup的缓存行为64字节,这就导致了一些问题。 例如,多个不需要同步的变量因为存储在连续的32字节或64字节里面,当需要其中的一个变量时,就将它们作为一个缓存行一起加载到某个cup-1私有的缓存中(虽然只需要一个变量,但是cpu读取会以缓存行为最小单位,将其相邻的变量一起读入),被读入cpu缓存的变量相当于是对主内存变量的一个拷贝,也相当于变相的将在同一个缓存行中的几个变量加了一把锁,这个缓存行中任何一个变量发生了变化,当cup-2需要读取这个缓存行时,就需要先将cup-1中被改变了的整个缓存行更新回主存(即使其它变量没有更改),然后cup-2才能够读取,而cup-2可能需要更改这个缓存行的变量与cpu-1已经更改的缓存行中的变量是不一样的,所以这相当于给几个毫不相关的变量加了一把同步锁; 为了防止伪共享,不同jdk版本实现方式是不一样的:
- 在jdk1.7之前会 将需要独占缓存行的变量前后添加一组long类型的变量,依靠这些无意义的数组的填充做到一个变量自己独占一个缓存行;
- 在jdk1.7因为jvm会将这些没有用到的变量优化掉,所以采用继承一个声明了好多long变量的类的方式来实现;
- 在jdk1.8中通过添加sun.misc.Contended注解来解决这个问题,若要使该注解有效必须在jvm中添加以下参数: -XX:-RestrictContended sun.misc.Contended注解会在变量前面添加128字节的padding将当前变量与其他变量进行隔离
synchronized
作用:原子性
实现:
字节码:monitorenter/monitorexit
jvm:锁升级
new
偏向锁
对象头的markword标记线程id
自旋锁/轻量级锁/无锁
本地线程会在栈中生成一个record,cas方式存入对象头
aba问题-version
重量级锁
操作系统级别的线程等待
操作系统: lock comxchg 指令
- 三种使用方式 先来看下利用synchronized实现同步的基础:Java中的每一个对象都可以作为锁。
- 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
- 静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
- 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
实现原理
synchronized是基于Monitor来实现同步的。 Monitor从两个方面来支持线程之间的同步:
- 互斥执行 Java 使用对象锁 ( 使用 synchronized 获得对象锁 ) 保证工作在共享的数据集上的线程互斥执行。下面重点分析java对象锁的类型 • 协作 使用 notify/notifyAll/wait 方法来协同不同线程之间的工作。Object的wait/notify机制可以看wati/notify的分析
锁的类型
轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。
偏向锁
偏向锁是JDK6中的重要引进,因为HotSpot作者经过研究实践发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,引进了偏向锁。
偏向锁是在单线程执行代码块时使用的机制,如果在多线程并发的环境下(即线程A尚未执行完同步代码块,线程B发起了申请锁的申请),则一定会转化为轻量级锁或者重量级锁。
引入偏向锁主要目的是:为了在没有多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。因为轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗也必须小于节省下来的CAS原子指令的性能消耗)。
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程进入和退出同步块时不需要花费CAS操作来争夺锁资源,只需要检查是否为偏向锁、锁标识为以及ThreadID即可,处理流程如下:
- 检测Mark Word是否为可偏向状态,即是否为偏向锁1,锁标识位为01;
- 若为可偏向状态,则测试线程ID是否为当前线程ID,如果是,则执行步骤(5),否则执行步骤(3);
- 如果测试线程ID不为当前线程ID,则通过CAS操作竞争锁,竞争成功,则将Mark Word的线程ID替换为当前线程ID,否则执行线程(4);
- 通过CAS竞争锁失败,证明当前存在多线程竞争情况,当到达全局安全点,获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块;
- 执行同步代码块;
偏向锁的释放采用了 一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要 等待全局安全点(这个时间点是上没有正在执行的代码)。其步骤如下:
- 暂停拥有偏向锁的线程;
- 判断锁对象是否还处于被锁定状态,否,则恢复到无锁状态(01),以允许其余线程竞争。是,则挂起持有锁的当前线程,并将指向当前线程的锁记录地址的指针放入对象头Mark Word,升级为轻量级锁状态(00),然后恢复持有锁的当前线程,进入轻量级锁的竞争模式;
轻量级锁
对于轻量级锁,其性能提升的依据是 “对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。
引入轻量级锁的主要目的是 在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:
- 在线程进入同步块时,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。此时线程堆栈与对象头的状态如下图所示:
- 拷贝对象头中的Mark Word复制到锁记录(Lock Record)中;
- 拷贝成功后,虚拟机将使用CAS操作尝试将对象Mark Word中的Lock Word更新为指向当前线程Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤(4),否则执行步骤(5);
- 如果这个更新动作成功了,那么当前线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态,此时线程堆栈与对象头的状态如下图所示:
- 如果这个更新操作失败了,虚拟机首先会检查对象Mark Word中的Lock Word是否指向当前线程的栈帧,如果是,就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,进入自旋执行(3),若自旋结束时仍未获得锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,当前线程以及后面等待锁的线程也要进入阻塞状态。
轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:
- 通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word;
- 如果替换成功,整个同步过程就完成了,恢复到无锁状态(01);
- 如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程;
重量级锁
Synchronized是通过对象内部的一个叫做 监视器锁(Monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为 “重量级锁”。
锁升级
锁膨胀方向: 无锁 → 偏向锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)
锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。 在 JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking来禁用偏向锁。
自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开开启,在JDK1.6中默认开启。同时自旋的默认次数为10次,可以通过参数-XX:PreBlockSpin来调整。 JDK1.6引入自适应的自旋锁,让虚拟机会变得越来越聪明
锁消除
在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。 在运行这段代码时,JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外,所以JVM可以大胆地将vector内部的加锁操作消除。
public void vectorTest(){
Vector<String> vector = new Vector<String>();
for(int i = 0 ; i < 10 ; i++){
vector.add(i + "");
}
System.out.println(vector);
}
锁粗化
原则上,我们都知道在加同步锁时,尽可能的将同步块的作用范围限制到尽量小的范围(只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小。在存在锁同步竞争中,也可以使得等待锁的线程尽早的拿到锁)。
大部分上述情况是完美正确的,但是如果存在连串的一系列操作都对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要地性能操作。
public static String test04(String s1, String s2, String s3) {
StringBuilder sb = new StringBuilder();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
在上述地连续append()操作中就属于这类情况。JVM会检测到这样一连串地操作都是对同一个对象加锁,那么JVM会将加锁同步地范围扩展(粗化)到整个一系列操作的 外部,使整个一连串地append()操作只需要加锁一次就可以了。
wait/notify
wait/notify 是为了线程间通信的,为了这个通信过程不被打断,需要保证 wait/notify 这个整体代码块的原子性,所以需要通过 synchronized 来加锁。
wait/nofity 是通过 jvm 里的 park/unpark 机制来实现的,在 linux 下这种机制又是通过 pthread_cond_wait/pthread_cond_signal 来玩的,因此当线程进入到 wait 状态的时候其实是会放弃 cpu 的,也就是说这类线程是不会占用 cpu 资源,也不会影响系统加载。
wait/notify 获得synchronize锁之后,调用wait会让当前线程进入等待队列并且释放锁 获得synchronize锁之后,调用notify会通知其他线程可以竞争synchronize锁,但并不会释放锁,需要等到monitorexit之后才会释放锁
Thread.sleep()和Object.wait()的区别
- Thread.sleep()不会释放占有的锁,Object.wait()会释放占有的锁;
- Thread.sleep()必须传入时间,Object.wait()可传可不传,不传表示一直阻塞下去;
- Thread.sleep()到时间了会自动唤醒,然后继续执行;
- Object.wait()不带时间的,需要另一个线程使用Object.notify()唤醒;
- Object.wait()带时间的,假如没有被notify,到时间了会自动唤醒,这时又分好两种情况,一是立即获取到了锁,线程自然会继续执行;二是没有立即获取锁,线程进入同步队列等待获取锁; 其实,他们俩最大的区别就是Thread.sleep()不会释放锁资源,Object.wait()会释放锁资源。
Unsafe
cas
Unsafe只提供了3种CAS方法:compareAndSwapObject、compareAndSwapInt和compareAndSwapLong。都是native方法。
public final native boolean compareAndSwapObject(Object paramObject1, long paramLong, Object paramObject2, Object paramObject3);
public final native boolean compareAndSwapInt(Object paramObject, long paramLong, int paramInt1, int paramInt2);
public final native boolean compareAndSwapLong(Object paramObject, long paramLong1, long paramLong2, long paramLong3);
不妨再看看Unsafe的compareAndSwap方法来实现CAS操作,它是一个本地方法,实现位于unsafe.cpp中。
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
UnsafeWrapper("Unsafe_CompareAndSwapInt");
oop p = JNIHandles::resolve(obj);
jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END
可以看到它通过 Atomic::cmpxchg 来实现比较和替换操作。其中参数x是即将更新的值,参数e是原内存的值。
如果是Linux的x86,Atomic::cmpxchg方法的实现如下:LOCK_IF_MP和cmpxchgl
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
int mp = os::is_MP();
__asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
: "=a" (exchange_value)
: "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
: "cc", "memory");
return exchange_value;
}
park/unpark
UNSAFE使用park和unpark进行线程的阻塞和唤醒操作 park和unpark底层是借助系统层(Linux下)方法pthread_mutex和pthread_cond来实现的
- 先获取pthread_mutex通过pthread_cond_wait函数可以对一个线程进行阻塞操作
- 通过pthread_cond_signal函数对一个线程进行唤醒操作,然后pthread_mutex_unlock
public native void unpark(Object var1);
public native void park(boolean var1, long var2);
parker实现如下
void Parker::park(bool isAbsolute, jlong time) {
if (_counter > 0) {
//已经有许可了,用掉当前许可
_counter = 0 ;
//使用内存屏障,确保 _counter赋值为0(写入操作)能够被内存屏障之后的读操作获取内存屏障事前的结果,也就是能够正确的读到0
OrderAccess::fence();
//立即返回
return ;
}
Thread* thread = Thread::current();
assert(thread->is_Java_thread(), "Must be JavaThread");
JavaThread *jt = (JavaThread *)thread;
if (Thread::is_interrupted(thread, false)) {
// 线程执行了中断,返回
return;
}
if (time < 0 || (isAbsolute && time == 0) ) {
//时间到了,或者是代表绝对时间,同时绝对时间是0(此时也是时间到了),直接返回,java中的parkUtil传的就是绝对时间,其它都不是
return;
}
if (time > 0) {
//传入了时间参数,将其存入absTime,并解析成absTime->tv_sec(秒)和absTime->tv_nsec(纳秒)存储起来,存的是绝对时间
unpackTime(&absTime, isAbsolute, time);
}
//进入safepoint region,更改线程为阻塞状态
ThreadBlockInVM tbivm(jt);
if (Thread::is_interrupted(thread, false) || pthread_mutex_trylock(_mutex) != 0) {
//如果线程被中断,或者是在尝试给互斥变量加锁的过程中,加锁失败,比如被其它线程锁住了,直接返回
return;
}
//这里表示线程互斥变量锁成功了
int status ;
if (_counter > 0) {
// 有许可了,返回
_counter = 0;
//对互斥变量解锁
status = pthread_mutex_unlock(_mutex);
assert (status == 0, "invariant") ;
OrderAccess::fence();
return;
}
当unpark时,则简单多了,直接设置_counter为1,再unlock mutex返回。如果_counter之前的值是0,则还要调用pthread_cond_signal唤醒在park中等待的线程:
void Parker::unpark() {
int s, status ;
//给互斥量加锁,如果互斥量已经上锁,则阻塞到互斥量被解锁
//park进入wait时,_mutex会被释放
status = pthread_mutex_lock(_mutex);
assert (status == 0, "invariant") ;
//存储旧的_counter
s = _counter;
//许可改为1,每次调用都设置成发放许可
_counter = 1;
if (s < 1) {
//之前没有许可
if (WorkAroundNPTLTimedWaitHang) {
//默认执行 ,释放信号,表明条件已经满足,将唤醒等待的线程
status = pthread_cond_signal (_cond) ;
assert (status == 0, "invariant") ;
//释放锁
status = pthread_mutex_unlock(_mutex);
assert (status == 0, "invariant") ;
} else {
status = pthread_mutex_unlock(_mutex);
assert (status == 0, "invariant") ;
status = pthread_cond_signal (_cond) ;
assert (status == 0, "invariant") ;
}
} else {
//一直有许可,释放掉自己加的锁,有许可park本身就返回了
pthread_mutex_unlock(_mutex);
assert (status == 0, "invariant") ;
}
}
Object
wait/notify
Java 中每一个对象都可以成为一个监视器(Monitor), 该 Monitor 由一个锁(lock), 一个等待队列(waiting queue ), 一个入口队列 (entry queue) 组成.
- 对于一个对象的方法, 如果没有 synchonized 关键字, 该方法可以被任意数量的线程,在任意时刻调用。
- 对于添加了 synchronized 关键字的方法,任意时刻只能被唯一的一个获得了对象实例锁的线程调用。
java.lang.Object 类定义了 wait(),notify(),notifyAll() 方法,这些方法的具体实现,依赖于一个叫 ObjectMonitor 模式的实现,这是 JVM 内部基于 C++ 实现的一套机制,基本原理如下所示:
- 进入区 (Entry Set): 表示线程通过 synchronized 要求获得对象锁,如果获取到了,则成为拥有者,如果没有获取到在在进入区等待,直到其他线程释放锁之后再去竞争 (谁获取到则根据)
- 拥有者 (Owner): 表示线程获取到了对象锁,可以执行 synchronized 包围的代码了
- 等待区 (Wait Set): 表示线程调用了 wait 方法,此时释放了持有的对象锁,等待被唤醒 (谁被唤醒取得监视器锁由 jvm 决定)
当一个线程需要获取 Object 的锁时,会被放入 EntrySet 中进行等待,如果该线程获取到了锁,成为当前锁的 owner。
如果根据程序逻辑,一个已经获得了锁的线程缺少某些外部条件,而无法继续进行下去(例如生产者发现队列已满或者消费者发现队列为空),那么该线程可以通过调用 wait 方法将锁释放,进入 wait set 中阻塞进行等待,其它线程在这个时候有机会获得锁,去干其它的事情,从而使得之前不成立的外部条件成立,这样先前被阻塞的线程就可以重新进入 EntrySet 去竞争锁。这个外部条件在 monitor 机制中称为条件变量。
对应的操作系统的线程同步原理
操作系统线程同步方法:Mutex(互斥量)、条件变量(CV,Condition Variable)、Semaphore(信号量)以及Monitor(监视器/管程) 其中Mutex、CV、Semaphore操作系统的pthread库中都有对应实现,但是监视器是没有的,需要实现
POSIX threads don't provide monitors specifically, but everything that you can do with a monitor, you can do with a mutex plus a condition variable.
与其说Monitor是一种机制,倒不如说它是一种风格(style),因为它并不是一种新的同步机制。Monitor所做的,就是把mutex和CV封装在一个对象里面,来保护这个对象的共有数据的访问. 其中Lock控制线程的进入,保证只有一个对象能拿到锁;而CV负责线程的等待、唤醒等操作;put和get是对shared data的一组访问方法。这种形式就是Monitor。
参考
tech.youzan.com/javasuo-de-… generalthink.github.io/2019/10/10/… liujiacai.net/blog/2018/1… www.yuque.com/michael-cm1…