什么是线程安全
想象一下这样一个场景:你去酒店开了一间房,可是房间没有上锁,任何从你房间路过的人都可以推门而入(如果他们想的话)。在这样的房间里睡觉 安全吗?显然是不安全的。路不拾遗、夜不闭户这种理想状态只会出现在理想中。
同样的,在多线程的情况下,也存在线程安全的问题。我们先理解下下面的代码:
private static int count = 0;
public static void main(String[] args) {
for (int i = 0; i < 2; i++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10);
} catch (Exception e) {
e.printStackTrace();
}
//每个线程让count自增100次
for (int i = 0; i < 100; i++) {
count++;
}
}
}).start();
}
try{
Thread.sleep(2000);
}catch (Exception e){
e.printStackTrace();
}
System.out.println(count);
}
counut 的输出值是否为 200?答案是否定的。因为 count++ 这个操作不是原子性的。
- 先要从主存获取count值到,然后+1,最后再把结果写到主存。
因为不是原子性的,就有可能被会其他线程打断,结果不确定性,这个程序就是线程不安全的。
如何保证线程安全
那么如何保证线程安全?首先说下众所周知的方法:悲观锁、乐观锁。
什么是悲观锁、乐观锁?在 java 语言里,总有一些名词看语义跟本不明白是啥玩意儿,也就总有部分面试官拿着这样的词来忽悠面试者,以此来找优越感,其实理解清楚了,这些词也就唬不住人了。
乐观锁
乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。
java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。
悲观锁
悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁。java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如RetreenLock。
CAS 机制
CAS 是英文单词 Compare And Swap (Compare And Exchange) 的缩写,翻译过来就是比较并替换。
因为经常配合循环操作,直到完成为止,所以泛指一类操作
cas(v, a, b) ,变量v,期待值a, 修改值b
更新一个变量的时候,只有当变量的预期值(计算结果值V) 和主内存当中的实际值(新值 N)相同时,才会将内存地址对应的值 N修改为 V。
ABA
简单说: 值A 变成 B 再变成 A。最终你只会看到A还是A,但是中间变成 B的过程你并不知道,这可能会产生不确定的影响。
解决:版本号( AtomicStampedReference),基础类型简单值不需要版本号每次被修改都改变版本号,每次读都顺带读出版本号。
原子操作类
轻量级锁,所谓原子操作类也称为无锁或者自旋锁,指的是 java.util.concurrent.atomic 包下,一系列以 Atomic 开头的包装类。例如AtomicBoolean
,AtomicInteger
,AtomicLong
。它们分别用于Boolean
,Integer
,Long
类型的原子性操作。
private static AtomicInteger count = new AtomicInteger(0);
...
for (int i = 0; i < 100; i++) {
count.incrementAndGet();
}
使用 AtomicInteger 之后,最终的输出结果同样可以保证是 200。并且在某些情况下,代码的性能会比 Synchronized 更好。
而 Atomic 操作的底层实现正是利用的 CAS 机制。
unsafe 底层实现
AtomicInteger:
public final int incrementAndGet() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return next;
}
}
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
Unsafe:
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
CAS底层的的实现方法是c++写的,底层实现:
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;
}
- asm 表示这是汇编指令
- LOCK_IF_MP:Multi Processors,如果是多个CPU,则前面加LOCK。CAS的关键就是这个LOCK。锁定一个北桥信号。
- cmpxchg :compare and exchang。(非原子性指令)
但是这种自旋(一直霸占着CPU却不做事)是很耗 cpu 资源的,所以一般情况下,都会有线程 sleep。
CAS 的缺点
1.CPU 开销较大
在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给 CPU 带来很大的压力。
2. 不能保证代码块的原子性
CAS 机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证 3 个变量共同进行原子性的更新,就不得不使用 Synchronized 了。
Synchronized
同步方法:作用于方法时,锁是当前调用该方法的对象,也就是this指向的对象。
同步方法是被共享的,那么该方法的对象相当于所有的线程来说就是唯一的,保证了锁的唯一性。
作用于静态方法时,锁是该方法所在类的class实例对象,该对象可以通过“类名.class”获取。
因为Class的相关数据存储在永久代PermGen(jdk1.8则是metaspace),永久代是全局共享的,因此静态方法锁相当于类的一个全局锁。
同步代码块:作用于一个共享实例对象时,锁住的是所有以该对象为锁的代码块。
(这里需要注意:锁住的是该共享实例对象,而不是下面的代码块!锁的是房间门,而不是床!)
一开始的案例我们用Synchronized
同步锁, 只需要在 count++ 的位置添加同步锁,让这个自加的过程捆绑具有原子性,其他线程不能插手这个过程。代码如下:
for (int i = 0; i < 100; i++) {
synchronized (ThreadCas.class){
count++;
}
Synchronized的实现
它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。
- Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;
- Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中;
- Wait Set:哪些调用wait方法被阻塞的线程被放置在这里;
- OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被称为OnDeck;
- Owner:当前已经获取到所资源的线程被称为Owner;
- !Owner:当前释放锁的线程。
JVM每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList会被大量的并发线程进行CAS访问,为了降低对尾部元素的竞争,JVM会将一部分线程移动到EntryList中作为候选竞争线程。Owner线程会在unlock时,将ContentionList中的部分线程迁移到EntryList中,并指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程)。Owner线程并不直接把锁传递给OnDeck线程,而是把锁竞争的权利交给OnDeck,OnDeck需要重新竞争锁。这样虽然牺牲了一些公平性,但是能极大的提升系统的吞吐量,在JVM中,也把这种选择行为称之为“竞争切换”。
OnDeck线程获取到锁资源后会变为Owner线程,而没有得到锁资源的仍然停留在EntryList中。如果Owner线程被wait方法阻塞,则转移到WaitSet队列中,直到某个时刻通过notify或者notifyAll唤醒,会重新进去EntryList中。
处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux内核下采用pthread_mutex_lock内核函数实现的)。
Synchronized是非公平锁。 Synchronized在线程进入ContentionList时,等待的线程会先尝试自旋获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源。
缺点
Synchronized
虽然确保了线程的安全,但是在性能上却不是最优的,Synchronized
关键字会让没有得到锁资源的线程进入BLOCKED
状态(阻塞或者轮询),而后在争夺到锁资源后恢复为RUNNABLE
状态,这个过程中涉及到操作系统用户模式和内核模式的转换,代价比较高。
尽管 Java1.6 为Synchronized
做了优化,增加了从偏向锁到轻量级锁再到重量级锁的过度,但是在最终转变为重量级锁之后,性能仍然较低。
锁释放的情况
当线程执行完了代码,释放锁
或者执行发生异常,JVM会让线程自动释放锁。
如果获取到锁的线程在等待IO或者其他原因(比如sleep)被阻塞了,但是又没有释放锁,其他线程只能等着,,,因此需要一种机制可以不让等待的线程无限期的等待。(Lock出现的原因)
Lock
synchronized 无法中断一个正在等候获得锁的线程,也无法通过轮询得到锁,如果不想等下去,也就没法得到锁。JDK5 增加了Lock锁。更加灵活。
Synchronized 与 Lock 的区别
实现层面不一样。synchronized是Java关键字,JVM层面实现加锁和释放锁;
lock是一个接口,在代码层面实现加锁和释放锁。
是否自动释放锁。synchronized在代码执行完或出现异常时自动释放锁;
lock不会自动释放锁,需要在finally代码块显式的释放锁 lock.unlock();
是否一直等待。synchronized会导致拿不到锁的线程一直等待;
lock可以设置尝试获取锁或者获取锁失败一定时间超时。lock.tryLock()
获取锁成功是否可知。synchronized无法得知;
lock可以通过tryLock 返回布尔值得知。
功能复杂性。synchronized 可重入、不可中断、非公平;
lock:可重入、可判断、可公平或者不公平、细分读写锁提高效率。
参考: