个人认为,高并发是一个程序猿最基本的要解决的问题,如何让程序在高并发下良好运行是考验一个程序猿功力深厚与否的标尺。而锁往往是解决高并发,线程同步的利器,博主根据自己的理解,参考了网上的许多面经、和文章加上自己的理解写下了这篇博客。
悲观锁适合写操作多的场景,先加锁可以保证写操作时数据正确。
乐观锁适合读操作多的场景,不加锁的特点能够使其读操作的性能大幅提升。
由于Synchronize是关键字,我们没办法通过鼠标点点点就知道其内部结构,只能通过反编译的手段来看看synchronize是怎么实现的。
Synchronize关键字具体的请看这位前辈写的,这里我就简写了
一个简单的demo
public class SynchronizedThis {
public void method() {
synchronized(this) {
}
}
}
//使用命令:javap -v 进行反编译,得到如下结果
public void method();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_1
5: monitorexit
6: goto 14
9: astore_2
10: aload_1
11: monitorexit
12: aload_2
13: athrow
14: return
java对象头:
|--------------------------------------------------------------|
| Object Header (64 bits) |
|------------------------------------|-------------------------|
| Mark Word (32 bits) | Klass Word (32 bits) |
|------------------------------------|-------------------------|
其中的Mark Word包含hashcode、gc分代年龄等等,而monitor就存在Mark Word中。
Synchronize关键字是通过monitorenter和monitorexit来实现,monitor可实现监视器的功能,调用monitorenter就是尝试获取这个对象,获取成功则+1,离开则-1,如果是线程重入,则继续+1,即synchronize是可重入的。既然是悲观锁,就证明用Synchronize修饰的方法或者属性,在多线程下只能被一个线程访问到。
CAS是Compare and Swap(比较并交换) 的简写,CAS的思想很简单:三个参数,一个当前内存值V、一个内存旧值A还有一个是新值B,当且仅当A==V时,即认为其他线程没有对数据进行修改则将B(新值)设置为内存值,并返回true,否则什么都不做 JVM中的CAS操作正是利用了处理器提供的CMPXCHG指令实现的;CAS操作会引起ABA带来的问题,可以通过版本号来解决。
抽象一个场景,小明有一百元,需要转给妈妈五十,来到银行ATM机,输入50,点击转账,由于不可避免因素,小明又重试了一次,而此时,小明的妈妈也给小明转了50,那么此时就有三个线程:
线程一:小明原有100,小明转账五十,新余额应有50
线程二:小明原有100,小明转账五十,新余额应有50//由于不可避免因素导致线程二阻塞,最后进行
线程三:预期小明应有50,妈妈转账五十,小明应有余额100
那么我们来分析一下,
首先线程一知道了银行卡原有一百,转账五十后发现没问题,确认
线程三预期和实际值一致,转账成功,此时小明应有100
线程三预期值、旧值一致,直接将余额赋值为50,此时小明痛哭
加了版本号呢,就是:1A,2B,3A,这时ABA问题得到解决。
自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令,那么效率会有一定的提升。pause指令有两个作用:第一,它可以延迟流水线执行指令,使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零;第二,它可以避免在退出循环的时候因内存顺序冲突而引起CPU流水线被清空,从而提高CPU的执行效率。
当对一个共享变量执行操作时,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。还有一个巧取的办法,就是把多个共享变量合并成一个共享变量来操作。比如,有两个共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij=2a,然后用CAS来操作ij。从JDK1.5开始提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作。
JVM的CAS操作是利用了处理器提供的CMPXCHG指令实现的。CAS通过调用JNI(Java native Interface本地C方法)的代码实现的。程序会根据当前处理器的类型来决定是否为COMPXCHG指令添加lock前缀:多CPU的情况下加lock前缀
cmpxchg是汇编指令
作用:比较并交换操作数.
如:CMPXCHG r/m,r 将累加器AL/AX/EAX/RAX中的值与首操作数(目的操作数)比较,如果相等,第2操作数(源操作数)的值装载到首操作数,zf置1。如果不等, 首操作数的值装载到AL/AX/EAX/RAX并将zf清0
该指令只能用于486及其后继机型。第2操作数(源操作数)只能用8位、16位或32位寄存器。第1操作数(目地操作数)则可用寄存器或任一种存储器寻址方式。
核心代码:即CAS操作,AtomticXXX所有的类都是基于CAS(比较并交换实现的),即使用了Unsafe.compareAndSwapInt进行更新,其内部主要是维护了一个用volatile修饰的·int类型的value
private volatile int value;
//比较并交换
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
我的理解:CAS能使其保证原子性,所以得到的AtmoticInteger也是一个原子数,而volatile关键字保证其可内存可见性。
变量修饰符,可以使用内存屏障保证变量的内存可见性以及禁止指令重排序,但是volatile不能保证原子性。
随着科技发展,为了提高处理器的执行速度,在处理器和内存之间增加了多级缓存来提升。但是由于引入了多级缓存,就存在缓存数据不一致问题。但是,对于volatile变量,当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中。
先看一段代码:
public class Singleton {
private volatile static Singleton singleton;
private Singleton() {
}
public static Singleton getInstance() {
if (singleton == null) {
// 1
sychronized(Singleton.class) {
if (singleton == null) {
singleton = new Singleton(); // 2
}
}
}
return singleton;
}
}
上述代码是一个典型的单例模式中的懒汉模式, singleton = new Singleton(); 可分三步:
假设虚拟机存在指令重排序优化,2、3调换位置。如果A线程率先进入同步代码块并先执行了3而没有执行2,此时因为singleton已经非null。这时候线程B到了1处,判断singleton非null并将其返回使用,因为此时Singleton实际上还未初始化,(并未有实际内容,我的理解),自然就会出错。sychronized可以解决内存可见性,但是不能解决重排序问题。
volatile的Integer自增i++,分成三步:
这三步的机器指令为:
mov 0xc(%r10),%r8d ; Load
inc %r8d ; Increment
mov %r8d,0xc(%r10) ; Store
lock addl $0x0,(%rsp) ; StoreLoad Barrier //内存屏障以确保一些特定的操作顺序和影响一些数据的可见性
从Load到store到内存屏障,一共4步,其中最后一步jvm让这个最新的变量的值在所有线程可见,也就是最后一步让所有的CPU内核都获得了最新的值,但**中间的几步(从Load到Store)**是不安全的,中间如果其他的CPU修改了值将会丢失。
在群里我忽然看到了这样一个点,volatile是可以解决内存可见性的问题,那么这个可见性到底是针对的CPU Cache而言即CPU的缓存导致的内存不可见还是基于JMM(java内存模型)而言呢?
从操作系统的角度来说,CPU 的执行效率会比内存的取指效率快的多,所以 CPU 会在内部设立缓存机制,就是为了弥补内存工作慢的原因,所以一般访问的时候会先从 CPU 自己的缓存访问,而 volatile 关键字是刷新缓存保证可见性,我认为应该是会让 CPU 强制从内存取出值,应该保证的是 JMM 内存可见
“先行发生”(happen—before)的规则,它是Java内存模型中定义的两项操作之间的偏序关系,如果操作A先行发生于操作B,其意思就是说,在发生操作B之前,操作A产生的影响都能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等,它与时间上的先后发生基本没有太大关系。这个原则特别重要,它是判断数据是否存在竞争、线程是否安全的主要依据。
我决定把这几个放到一起,因为有个叫ReentrantLock的锁可以很好的去包容上述特性。首先源码:
public class ReentrantLock implements Lock, java.io.Serializable
可以看到看到ReentrantLock实现了lock,而说lock呢,又不得不说一个东西:AQS
回过头来,我试着用比较Synchronize和lock的区别的方法:
说完了这些,再回到上面的概念:
广义上的可重入锁指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁(前提得是同一个对象或者class),这样的锁就叫做可重入锁。ReentrantLock和synchronized都是可重入锁
那么不可重入锁就反之了
顾名思义,就是可以相应中断的锁。synchronized就不是可中断锁,而Lock是可中断锁。
如果某一线程A正在执行锁中的代码,另一线程B正在等待获取该锁,可能由于等待时间过长或其他原因,线程B中断等待,去做其他事情,那么就称该锁是可以中断的。
公平锁,就是公平的锁。。。。
即根据等待获取锁时间而优先获得对象的锁的一种锁
ReentrantLock可以设置为公平锁,默认情况下不是公平锁的:
读写锁,类似于数据库的S(共享)锁,即只允许一个线程去写,多个线程去读,这样可以提高并发度
ReentrantReadWriterLock就是一个可以同时读又可以同时写的一种锁
public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable {
private static final long serialVersionUID = -6992448646407690164L;
/** Inner class providing readlock */
private final ReentrantReadWriteLock.ReadLock readerLock;
/** Inner class providing writelock */
private final ReentrantReadWriteLock.WriteLock writerLock;
/** Performs all synchronization mechanics */
final Sync sync;
首先声明一下,这三个锁并不是Java语言中的锁,而是针对Synchronize关键字进行优化的三种方式,因为Synchronize关键字过于笨重,所以jdk对关键字进行了优化。
在此引申到对象头中MarkWord的内容:
其中:
lock:2位的锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了lock标记。该标记的值不同,整个mark word表示的含义不同。
biased_lock | lock | 状态 |
---|---|---|
0 | 01 | 无锁 |
1 | 01 | 偏向锁 |
0 | 00 | 轻量级锁 |
0 | 10 | 重量级锁 |
0 | 11 | GC标记 |
偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。
轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会自动升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。
上面提及了自旋锁的概念,自旋锁顾名思义自己旋转获取锁,这样的好处是避免了上下文切换,但是由于一直自旋,会一直占用CPU,使用不当还会使CPU飚。而自旋锁本身无法保证公平性,同时也无法保证可重入性。基于自旋锁,可以实现具备公平性和可重入性质的锁。
这个其实就有点牵强了,这个应用到的地方我知道的(恕我才疏学浅)就只有ConcurrentHashMap,JDK1.7的ConcurrentHashMap是基于分段锁,jdk1.7中采用Segment + HashEntry的方式进行实现,结构如下:
所谓的分段,顾名思义,将一个个Entry分成数段,每一段进行加锁,这样既保证了较为理想的并发度,而且保证了线程安全
轻量级锁不是在任何情况下都比重量级锁快的, 要看同步块执行期间有没有多个线程抢占资源的情况,如果有,那么轻量级线程要承担 CAS + 互斥量锁的性能消耗,就会比重量锁执行的更慢。