加锁是为了避免在并发环境下,同时访问共享资源产生的风险问题。那么,在并发环境下,是否必须加锁?答案是否定的。并非所有的并发都需要加锁。适当地降低锁的粒度,甚至采用无锁化的设计,更能提升并发能力。
比如,JDK中的ConcurrentHashMap,巧妙地采用了桶粒度的锁,避免了put和get中对整个map的锁定,尤其在get中,只对一个HashEntry做锁定操作,性能提升是显而易见的。
又比如,在程序中可以合理考虑业务数据的隔离性,实现无锁化的并发。例如,程序中预计会有两个并发任务,每个任务可以对所需要处理的数据进行分组。任务1去处理尾数为0到4的业务数据,任务2处理尾数为5到9的业务数据。那么,这两个并发任务所要处理的数据天然是隔离的,也就不需要加锁。
阿里有一道笔试题如下
无锁化编程有哪些常见方法?
-
针对计数器,可以使用原子加
-
只有一个生产者和一个消费者,那么就可以做到免锁访问环形缓冲区(Ring Buffer)
-
RCU(Read-Copy-Update),新旧副本切换机制,对于旧副本可以采用延迟释放的做法
-
CAS(Compare-and-Swap),如无锁栈,无锁队列等待
这四种都是,
A 原子操作是汇编级别支持的指令lock xadd,java中有AutomicInteger都是对其的封装。简单变量的线程同步用这种方式效率最高。
B 多个生产者和多个消费者,一样可以做到免锁访问,但要使用原子操作。这里的意思应该是不用原子操作级别的免锁,理由也很简单,生产者和消费者需要修改的位置是分开的(生产者加在尾部,消费者从头部消费),且只有一个读一个写,不会发生冲突。所以只有一点需要关注,就是尾部指针和头部指针每次需要比较以避免生产溢出或者过度消费,而简单变量的读操作都是原子的。
C 类似的一个概念叫CopyOnWrite,复制一份,修改完后,替换回去时只需替换一个指针或引用,锁住的粒度非常小。但有可能还有线程持有的是旧的指针,因此旧的副本需要延迟释放。
D 汇编级别支持的指令cmpxchg,锁定内存地址,比较地址中修改前的内容是否与修改时的值一致,如果不一致就说明有其他线程改动,需要重新做。如,内存地址0x123456中原来存放的是10101010,但CPU执行到cmpxchg指令时,发现内存中变成了11111111,那么就认为其他线程已经修改了这个地址的值,需要重新读取0x123456中的值11111111,再做一次cmpxchg,如果这次发现内存中仍然是11111111,那么cmpxchg就会把新的值写入到0x123456中去。这里面有个ABA问题,就是有线程改了2次从11111111 -> 10111111 -> 11111111,那么CAS操作是识别不了的,需要从业务层去避免,如果直接在0x123456再放一个地址值,而地址值如果不先释放再重新申请内存,就不会出现重复值。
其中,串行无锁化就是选项B采用的思想,大名鼎鼎的协程其实也是这种思想的应用,线程切换的时候,会保存在CPU的寄存器里面, 协程切换的时候,却都是由用户自己的实现的。协程拥有自己的寄存器上下文和栈。
在netty中,也采用了这样的思想, NioEventLoop维护了一个任务队列,队列在创建NioEventLoop时被初始化,是用来实现串行无锁化的载体。 NioEventLoop封装了一个线程,用来处理客户端的连接事件,读写事件,以及处理任务队列中的任务。
NioEventLoop继承SingleThreadEventLoop,SingleThreadEventLoop继承SingleThreadEventExecutor。其中这个线程在SingleThreadEventExecutor中定义:
private volatile Thread thread;
任务队列则是这样定义:
//SingleThreadEventExecutor类 this.taskQueue = this.newTaskQueue(this.maxPendingTasks);
//NioEventLoop类 protected Queue<Runnable> newTaskQueue(int maxPendingTasks) { return maxPendingTasks == 2147483647 ? PlatformDependent.newMpscQueue() : PlatformDependent.newMpscQueue(maxPendingTasks); }
上面任务队列的实现就是调用inEventLoop()先通过thread来判断当前线程是否是创建NioEventLoop时绑定的线程,如果是就直接执行读写操作,如果不是就说明是其他线程,把读写操作封装成任务放在任务队列中。inEventLoop源码:
//SingleThreadEventExecutor private volatile Thread thread; public boolean inEventLoop(Thread thread){ return thread = this.thread }
NioEventLoop封装的线程在SingleThreadEventExecutor内定义,并在创建的时候初始化。
这样一次完整的工作流程就这样完成,然而这样设计CPU利用率其实并不高,并发程度不够。但这种设计是线程安全的,Netty线程间不需要做同步控制,Netty可以通过调整NIO线程池的线程参数,可以同时启动多个串行化的线程并行运行,这样性能就提升了。