为什么要加锁: 因为并发场景下对同一块内存的更新操作会引起bug。怎么加锁?有没有办法避免加锁?这里整理下自己对锁的思考。
基础概念
并发场景下的bug原因一般可归为三类。
-
可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。在多核并发场景下,每个CPU有自己的缓存。当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。所以单个线程的操作并不是马上对其他线程可见。
-
原子性:一个或者多个操作在CPU执行的过程中不被中断。操作系统按时间片对多线程、进程分时复用,而一条程序语言(多个CPU指令)就可能被切割成多个步骤在多个线程中交叉执行。这中间就不能保证单个线程的一个操作和其他线程不冲突。
-
有序性:代码执行顺序和编写顺序一致。高级语言的编译器优化带来的执行顺序问题。
锁的分类
锁的种类繁多,最终不过两点考虑的平衡:保证互斥和减少锁竞争。加锁是为了保证互斥,但毫无疑问会影响执行效率。所以一般会根据业务特点来减少锁竞争,比如减少锁的持有时间、降低锁的请求频率、使用带有协调机制的独占锁,这些机制允许更高的并发性。
-
根据加不加锁可以划分为:
① 悲观锁:比较严格,总是假设最坏的情况,每次取数据时都认为其他线程会修改,所以都会加锁阻止其他线程执行。
② 悲观锁:操作时不会上锁,一般通过版本号机制或CAS来实现。 -
根据线程获取锁的顺序划分为:
公平锁:多个线程按照申请锁的顺序来获取锁。
非公平锁:多个线程获取锁的顺序并不是按照申请锁的顺序,所以有可能会造成优先级反转或者饥饿现象。
分布式锁
分布式锁本质上和单机锁没有差别:在一个集群中,lock只能在同一时间只能被一台机器上的一个线程所拥有。比较流行的实现框架有redis、zk等。
java中的锁
-
volatile:这个关键词并不是java特有的,很多编程语言都会采用volatile去解决可见性问题。volatile的含义是禁用CPU缓存,告诉cpu绕过缓存直接操作内存。但这个并不能解决原子问题。
-
synchronized:这其实就是java对lock(可重入锁)的封装工具,始终保证一个代码块的访问控制。特别要注意使用synchronized的粒度。
-
工具类:Atomic(原子操作类)、ThreadLocal(线程安全的全局变量)、Concurrent(细粒度分段锁+Volatile)…
无锁编程
无锁编程最容易想到的就是数据不变或者消除共享,没有共享就不用加锁。比如java中的final(数据不变性)、Thread Local Storage(线程本地存储)都是这种思路。但一个大型项目完全无锁是不太现实的,通常都是采用无锁操作或者无锁的数据结构来提高系统性能。当然还有各种无锁的编程模型来实现无锁并发。
-
无锁操作:常见的无锁操作有CAS、Copy-on-write等。
① CAS(Compare-and-Swap),即比较并替换,当原始值和期望值一致的时候,替换目标变量的内存地址。CPU的总线锁能保证真正意义上的原子操作,CAS一般加上循环来实现并不是很严格的原子操作(比如ABA问题)
② Copy-on-write。核心思想是空间换时间,复制数据到新内存块,更新完改变引用地址。和很多DB的数据分版本思想类似,只能保证最终一致性。 -
无锁数据结构:常见的无锁数据结构有无锁队列(lock free queue)、无锁容器(b+tree、list、hashmap)等。以无锁队列为例,可以通过链表或者环形数组(ring buffer)+CAS出入队列操作来实现,这其中以Disruptor为优秀代表。