前言
随着Java的发展,并发这个概念不再是服务端独有的了。Android等移动端在App越做越大的趋势下,合理地控制并发也成为了一个非常重要的技能。在这里对Android开发中可能用到的并发知识做一个记录,以作备忘。
线程
提到并发不得不提线程。线程就是CPU将计算的时间片,分配给某个任务。线程与CPU核心数量并不直接关系,单核CPU也可以模拟出多线程,只是它将时间片分给不同的线程,让他们交叉执行任务。
值得注意的是,线程的主要作用是让任务能够并行执行。它并不能保证任务能够更快的执行。尤其在单核CPU上,对于单个任务,多线程的执行一定比单线程慢。
在现在的移动端开发中,虽然越来越讲究模块化、组件化,但是线程资源依然是一个App有限的资源。不论模块之后有多解耦,其线程资源依然是耦合的且有限的。
线程的基本操作
sleep(long millis)
使当前线程(即调用该方法的线程)暂停执行一段时间,让其他线程有机会继续执行,但它并不释放对象锁。
在CPU资源有限时,它可以使低优先级的线程得到执行的机会。
同时sleep在线程被中断时,会抛出相应的异常。
join()
join的作用让当前线程等待另一个线程。大致的使用方法如下。
class AThread extends Thread {
BThread bt;
public AThread(BThread bt) {
this.bt = bt;
}
public void run() {
try {
bt.join();
} catch (Exception e) {
}
}
}
我在AThread的任务中,调用了BThread的join方法。在调用BThread的join方法后,AThread会阻塞住等待BThread完成后再继续执行。
yield()
它与sleep()类似,只是不能由用户指定暂停多长时间,并且yield()方法只能让同优先级的线程有执行的机会。
并发与同步
在真正的并发编程中,最大的问题即是同步问题。
当多个线程同时访问一份资源时就可能出现一些问题:
典型的冲突有:
(1)丢失更新:一个事务的更新覆盖了其它事务的更新结果,就是所谓的更新丢失。例如:用户A把值从6改为2,用户B把值从2改为6,则用户A丢失了他的更新。
(2)脏读:当一个事务读取其它完成一半事务的记录时,就会发生脏读取。例如:用户A,B看到的值都是6,用户B把值改为2,用户A读到的值仍为6。
面对这样的情况,我们通常采用加锁的思路。
简单来讲:有些资源只有一份,当多个线程要同时访问和修改时,我们需要保证只有一个线程能够访问,当线程A在访问或修改时,线程B并不能再访问或修改它了。当A访问或修改结束,线程B才能够访问或修改它。
从加锁的策略上来讲,锁可以分为两类:
乐观锁与悲观锁
乐观锁和悲观锁从设计上来讲是有所不同的。
乐观锁:假定线程的访问和操作不会引起同步问题。如果引起了同步问题,则重新操作。
悲观锁:假定线程的访问和操作一定会引起同步问题 。因此绝对不允许多个线程同时锁问单一资源。
参考:
写得非常棒。
在Android中,通常悲观锁有Synchronized和ReentrantLock两种手段。而乐观锁则常用CAS。
Synchronized
synchronized
可以对代码块上锁,让一段代码在同一时间段,只有一个线程可以访问。
Object o1 = new Object();
synchronized(o1){
func1();
}
在func1()被调用前通过synchronized(o1)
,会获得o1这个对象的锁。其他方法要经过synchronized(o1)
时也需要获得o1这个锁,如果o1已经被其他线程获取,那么这个线程就必须等待。
如果只是这样,那synchronized
的功能会非常难用。因为我们在实际开发中,可能遇到,我的线程1在执行任务时,发现任务执行不下去了,需要等待其他资源。而此时,我又无法释放锁,后面等着的线程2,线程3因为锁的问题,无法执行任务。
因此对象有wait/notify机制。
synchronized(对象){
while(条件不满足){
对象.wait();
}
doSomething();
}
synchronized(对象){
改变条件;
对象.notify();
}
在线程1中当.wait()
调用,会立即释放对象锁。其他线程就可以执行了。当条件改变时,线程1需要重新获得锁继续执行任务时,调用.notify()
方法,让线程1获得这个锁,同样也可以用.notifyAll()
方法,让所有线程去竞争这个锁,此时是非公平锁,谁能够竞争到锁,由JVM决定。
ReentrantLock
有了wait()、notify()的机制虽然不错,但两个线程需要比较复杂的条件进行配合时,仅仅凭借wait/notify就不那么方便了。
举个粟子,我需要两个线程,一起给一个int型的数做递增。从0 - 3 由 线程A完成,此时线程B等待,3 - 6 由线程B完成,此时线程A等待。最后由线程A做最后的总结。
在这样的情景下,synchronized就不那么好用了,因为在我们的情景中,需要有两个条件,控制同一个锁。
而使用ReentrantLock则非常简单。
package cn.outofmemory.locks;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class App {
static class NumberWrapper {
public int value = 1;
}
public static void main(String[] args) {
//初始化可重入锁
final Lock lock = new ReentrantLock();
//第一个条件当屏幕上输出到3
final Condition reachThreeCondition = lock.newCondition();
//第二个条件当屏幕上输出到6
final Condition reachSixCondition = lock.newCondition();
//NumberWrapper只是为了封装一个数字,一边可以将数字对象共享,并可以设置为final
//注意这里不要用Integer, Integer 是不可变对象
final NumberWrapper num = new NumberWrapper();
//初始化A线程
Thread threadA = new Thread(new Runnable() {
@Override
public void run() {
//需要先获得锁
lock.lock();
try {
System.out.println("threadA start write");
//A线程先输出前3个数
while (num.value <= 3) {
System.out.println(num.value);
num.value++;
}
//输出到3时要signal,告诉B线程可以开始了
reachThreeCondition.signal();
} finally {
lock.unlock();
}
lock.lock();
try {
//等待输出6的条件
reachSixCondition.await();
System.out.println("threadA start write");
//输出剩余数字
while (num.value <= 9) {
System.out.println(num.value);
num.value++;
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
);
Thread threadB = new Thread(new Runnable() {
@Override
public void run() {
try {
lock.lock();
while (num.value <= 3) {
//等待3输出完毕的信号
reachThreeCondition.await();
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
try {
lock.lock();
//已经收到信号,开始输出4,5,6
System.out.println("threadB start write");
while (num.value <= 6) {
System.out.println(num.value);
num.value++;
}
//4,5,6输出完毕,告诉A线程6输出完了
reachSixCondition.signal();
} finally {
lock.unlock();
}
}
});
//启动两个线程
threadB.start();
threadA.start();
}
}
CAS
CAS不同于上面两种加锁方式,它是乐观锁。
CAS:Compare and Swap, 翻译成比较并交换。
java.util.concurrent包中借助CAS实现了区别于synchronouse同步锁的一种乐观锁。
CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。
非阻塞算法 (nonblocking algorithms)
一个线程的失败或者挂起不应该影响其他线程的失败或挂起的算法。
现代的CPU提供了特殊的指令,可以自动更新共享数据,而且能够检测到其他线程的干扰,而 compareAndSet() 就用这些代替了锁定。
拿出AtomicInteger来研究在没有锁的情况下是如何做到数据正确性的。
/**
* Atomically sets the value to the given updated value
* if the current value {@code ==} the expected value.
*
* @param expect the expected value
* @param update the new value
* @return {@code true} if successful. False return indicates that
* the actual value was not equal to the expected value.
*/
public final boolean compareAndSet(int expect, int update) {
return U.compareAndSwapInt(this, VALUE, expect, update);
}
其中
unsafe.compareAndSwapInt(this, valueOffset, expect, update);
类似:
if (this == expect) {
this = update
return true;
} else {
return false;
}
那么问题就来了,成功过程中需要2个步骤:比较this == expect,替换this = update,compareAndSwapInt如何这两个步骤的原子性呢? 参考CAS的原理。
CAS通过调用JNI的代码实现的。JNI:Java Native Interface为JAVA本地调用,允许java调用其他语言。
而compareAndSwapInt就是借助C来调用CPU底层指令实现的。
下面从分析比较常用的CPU(intel x86)来解释CAS的实现原理。
下面是sun.misc.Unsafe类的compareAndSwapInt()方法的源代码:
public final native boolean compareAndSwapInt(Object o, long offset,
int expected,
int x);
可以看到这是个本地方法调用。这个本地方法在openjdk中依次调用的c++代码为:unsafe.cpp,atomic.cpp和atomicwindowsx86.inline.hpp。这个本地方法的最终实现在openjdk的如下位置:openjdk-7-fcs-src-b147-27jun2011\openjdk\hotspot\src\oscpu\windowsx86\vm\ atomicwindowsx86.inline.hpp(对应于windows操作系统,X86处理器)。下面是对应于intel x86处理器的源代码的片段:
// Adding a lock prefix to an instruction on MP machine
// VC++ doesn't like the lock prefix to be on a single line
// so we can't insert a label after the lock prefix.
// By emitting a lock prefix, we can define a label after it.
#define LOCK_IF_MP(mp) __asm cmp mp, 0 \
__asm je L0 \
__asm _emit 0xF0 \
__asm L0:
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
// alternative for InterlockedCompareExchange
int mp = os::is_MP();
__asm {
mov edx, dest
mov ecx, exchange_value
mov eax, compare_value
LOCK_IF_MP(mp)
cmpxchg dword ptr [edx], ecx
}
}
如上面源代码所示,程序会根据当前处理器的类型来决定是否为cmpxchg指令添加lock前缀。如果程序是在多处理器上运行,就为cmpxchg指令加上lock前缀(lock cmpxchg)。反之,如果程序是在单处理器上运行,就省略lock前缀(单处理器自身会维护单处理器内的顺序一致性,不需要lock前缀提供的内存屏障效果)。
intel的手册对lock前缀的说明如下:
- 确保对内存的读-改-写操作原子执行。在Pentium及Pentium之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其他处理器暂时无法通过总线访问内存。很显然,这会带来昂贵的开销。从Pentium 4,Intel Xeon及P6处理器开始,intel在原有总线锁的基础上做了一个很有意义的优化:如果要访问的内存区域(area of memory)在lock前缀指令执行期间已经在处理器内部的缓存中被锁定(即包含该内存区域的缓存行当前处于独占或以修改状态),并且该内存区域被完全包含在单个缓存行(cache line)中,那么处理器将直接执行该指令。由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定(cache locking),缓存锁定将大大降低lock前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。
- 禁止该指令与之前和之后的读和写指令重排序。
- 把写缓冲区中的所有数据刷新到内存中。
这一段转载自 http://blog.csdn.net/hsuxu/article/details/9467651 讲的非常好
通过上面三种的对比,我们大致可以看到,synchronized是悲观锁,ReentrantLock是一种性能更强大的悲观锁,虽然在最新的JDK中,synchronized和ReentrantLock的性能已经相差无几,但是ReentrantLock确实API更友好。而CAS则是一个乐观锁,适用于锁资源争夺不激烈的情况。
网友评论