前言
本篇博客将对线程并发的一些基础知识进行阐述,大家也可以参考楼主以前关于线程的2篇博客:《Java多线程感悟一》、《Java多线程感悟二》 。
CPU、进程、线程
我们知道进程是操作系统进行资源分配的最小单位,一个进程内部可以有多个线程进行资源的共享,线程作为CPU调度的最小单位,CPU会依据某种原则(比如时间片轮转)对线程进行上下文切换,从而并发执行多个线程任务。打个比喻,CPU就像高速公路一样,每条高速公路会有并排的车道,而线程就像在路上行驶的汽车一样。我们可以通过/proc/cpuinfo来查看服务器有几个CPU,以及每个CPU支持的核心线程数,这样我们就了解了服务器有几条高速公路,以及每条高速公路有几个并排的车道。
多线程引发的思考
粗粒度的来讲,JAVA对内存的划分可以分为:堆和栈。对于多线程而言,堆就好像主内存,而栈就像是工作内存。堆是多线程共享的,线程工作时要将堆中的数据 COPY TO 工作内存才能进行工作。而线程什么时候COPY DATA TO工作内存?工作内存中的数据计算完毕又什么时候写回主内存?当多个线程之间对共享的数据进行读写,那么这一瞬间的读写是个什么顺序呢?一个线程能否看到或者什么时候才能看到另一个线程的改变呢?读和读的线程是否不需要控制并发呢?当有写线程参与时,对读线程有什么影响呢?写线程存在时,读线程是否一定要等呢?线程需要完成一连串的读写操作,是否允许其他线程插入进来呢?
多线程的基础:可见性
正如上面所言,由于存在主内存以及工作内存,每一个线程都是在自己的工作内存中进行工作的,如果线程在自己的区域埋头苦干,却不知道其他线程已经对共享的数据做出修改,这将会引发“可见性”问题。比如我们有一个配置文件,有一个写线程会对配置参数进行修改,其他很多读线程读取配置进行业务上的计算,如果写线程修改了,可是读线程依旧按照老的配置进行,也不知道读线程什么时候能“醒悟”,这多么可怕!当然JAVA已经为我们提供了轻量级的volatile来解决这个问题。(volatile不仅仅提供可见性,而且对于CPU/编译器优化带来的代码重排性也做了限制)
不仅仅可见
可见,这只不过是一瞬间的事情,更多时候,我们要的是一段时间内的操作的封闭性,即原子性。一个对象,它可以执行很多代码,但是我们希望它在执行某段代码(即临界区)时能够有一些限制,比如只允许一个线程对这个对象进行这段代码的操作,第二个线程要想操作必须等待第一个线程结束后。说的直白点,这个对象就好像一把锁,它存在3个临界区,那么这个对象在任意时刻只能处在一个临界区内!
synchronized
通过synchronized来对对象的代码进行临界区划分,从而完成可见性以及原子性的要求。synchronized是隐式的锁方式,因为加锁和解锁的过程是JAVA帮助我们来进行的,无需我们关心。正是由于这种隐式的方式,我们应重点关注的是synchronized锁住的是什么?锁住的是对象?还是锁住的是对象的临界区?锁对象的生命周期是什么?锁对象的粒度多大,是否可以优化?是否因为锁对象的粒度太大导致代码的串行,使得系统效率低下?
Lock
synchronized是JAVA最为古老的,也在不断优化的锁机制,在JAVA发展过程中也推出了新的锁机制:Lock。Lock是显式的锁,需要手动的上锁以及解锁。特别需要注意的是必须fiannly解锁,否则会出现死锁现象。第一个常用的锁是:ReentrantLock ,这是一个排他锁,和synchronized功能类似,不管线程是读,还是写,都是互斥的。第二个常用的锁是:ReentrantWriteReadLock,这是读写锁,如果读,用readLock,如果写,用writeLock,从而达到读与读的并发,读写之间的互斥。
Atomic与CAS机制
很多时候,我们仅仅希望对某个变量做一系列简单的动作,希望保证可见性以及原子性的操作,JAVA已经为我们提供了Atomic相关的类,使用最为广泛的就是AtomicInteger。这类Atomic虽然没有利用synchronized/Lock这样的锁机制,但是通过CAS达到了同样的目的。看一段AtomicInteger的代码:
一段死循环,先获取old值,然后尝试对比修改为新值,虽然没有临界区的锁控制,多个线程并发进行修改,但是显然compareAndSet保证了只会有一个线程能成功(相当于获得锁),这就是CAS机制。如果我们将死循环改成有限几次尝试CAS修改的话,就是自己设置了自旋的次数了。
用空间换时间:CopyOnWrite机制
在前文涉及的锁机制,都无法避免一个问题:一旦存在写线程,那么读线程势必无法并发进行。那么可否让读写并发进行呢?
CopyOnWrite机制:对于一个容器而言,多个读线程可以并发的读取该容器的内容;如果存在写线程,那么先COPY一份此容器,写线程对COPY的容器进行操作,待写线程操作完毕后,将老的容器的引用重置为COPY后的容器。这样一来,读写线程操作的容器不是同一个容器,当然可以并发进行操作。通过Copy的机制,利用空间来换取时间,需要注意的是当大量存在写线程时对内存的消耗。
并发编程集合类
StringBuffer 和 StringBuilder
StringBuffer的方法都打上了synchronized标签,自然是线程安全的;后来JDK走了一个极端,为我们提供了StringBuilder这样的非线程安全类,在单线程的环境下,提升了性能。
Hashtable 、 HashMap 、ConcurrentHashMap
Hashtable和HashMap同上面的StringBuffer/StringBuilder一样。
后来JDK出现了java.util.concurrent并发包,比如ConcurrentHashMap就通过分解锁的粒度,提高并发能力。下面我们来仔细剖析下ConcurrentHashMap的实现原理:
对于Hashtable/HashMap而言,其实里面存放的K/V并没有分层处理,对于Hashtable而言,如果锁,那么意味着锁住整个Hashtable的内容,意味着就算是读与读也得串行进行。而ConcurrentHashMap则将K/V进行划分,多个K/V成为一个segment,默认有16个segment,显然不同segment之间的读写可以并发进行,自然将锁的粒度一下子降低16倍。在每个segment内部,实际上借助于extends ReentrantLock实现读写互斥;而不同segment之间则不存在互斥关系。
CopyOnWriteArrayList 、 CopyOnWriteArraySet 、ArrayList 、Vector
Vector和ArrayList类似于StringBuffer/StringBuilder一样。
我们来看一段CopyOnWriteArrayList的代码,揭开CopyOnWrite机制:
add时利用排他锁达到互斥,在代码中可以看到Arrays.copyOf进行COPY,增加完元素后,利用setArray达到引用重置的目的。
再来看看获取元素的代码:
可以看到,没有锁的限制,读写并发进行操作!
本文转自zfz_linux_boy 51CTO博客,原文链接:http://blog.51cto.com/zhangfengzhe/1750569,如需转载请自行联系原作者