Android中的线程线程安全 & 线程同步
Posted 川峰
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android中的线程线程安全 & 线程同步相关的知识,希望对你有一定的参考价值。
文章目录
线程安全
线程安全又叫线程同步,在Java中多个线程同时访问一个可共享的资源变量时,有可能会导致数据不准确,导致使用该变量的逻辑出现问题,因此使用同步锁来保证一个线程对该资源的操作完成之前,其他线程不会对其进行操作,即保证线程同步安全。
因此线程同步的本质就是线程排队,这可能跟它的字面意思相反,它的目的就是保证线程按照先后顺序一个一个的访问共享资源,为了避免同时操作同一个共享资源。
在Java中实现线程安全的主要手段有:
- synchronized 同步锁
- ReentrantLock 重入锁
- volatile 线程可见
- Atomic 原子类家族,如AtomicInteger、AtomicBoolean等
- ThreadLocal 本地副本
- Semaphore 信号量
- ReadLock/WriteLock 读写锁
- Concurrent 线程安全的集合类,如ConcurrentHashMap等
- BlockingQueue 阻塞队列
- CountDownLatch 计数器
synchronized 同步锁
synchronized
关键字为同步锁,可重入锁,可以修饰普通方法、静态方法、代码块,但构造方法和成员变量除外。
同步方法:
public class Foo {
public synchronized void method() {
//...
}
}
这等价于:
public class Foo {
public void method() {
synchronized(this) {
//...
}
}
}
加在普通方法上的synchronized
关键字修饰使用的同步锁的是当前对象,就是调用当前方法的那个对象,注意这里是指同一个对象,假如我有线程A、线程B,Foo的实例obj1, Foo的实例obj2,那么只有线程A和线程B同时调用obj1的method()
方法(或同时调用obj2的),这时才会导致同步锁竞争,假如线程A访问的是obj1的method()
方法,而线程B访问的是obj2的method()
方法,那么它们互不影响,不会产生竞争,能同时访问执行。而访问同一个对象的不同的普通同步方法,也会产生竞争,因为它们是使用同一个this对象,竞争同一个锁。也就是说看是否会产生竞争本质是看使用的锁是否是同一个对象。
同步静态方法:
public class Foo {
public synchronized static void method() {
//...
}
}
这等价于:
public class Foo {
public static void method() {
synchronized (Foo.class) {
//...
}
}
}
加在静态方法上面的synchronized
关键字使用的锁是当前的类对象(Class<Foo>
)作为同步锁,可以理解下面的代码:
public class Foo {
public static void method() {
Class<Foo> obj = Foo.class;
synchronized (obj) {
//...
}
}
}
假如有多个线程内同时调用Foo.method()
时就会导致竞争,但是假如Foo
类内部还有一个普通的非静态同步方法method2()
, 那么当不同线程同时访问method()
和 method2()
方法时,它们并不会导致竞争,可以同时执行,因为它们所使用的加锁对象不同,一个是Foo
类的实例对象Foo obj
,一个是Class
类的对象Class<Foo> obj = Foo.class
同步代码块:
public class Foo {
public static final Object lock = new Object();
public void f1() {
synchronized (lock) { // lock 是公用同步锁
// 代码段 A
// 访问共享资源 resource1
// 需要同步
}
}
public void f2() {
synchronized (lock) { // lock 是公用同步锁
// 代码段 B
// 访问共享资源 resource1
// 需要同步
}
}
}
前面的普通同步方法和静态同步方法本质上都是同步代码块的实现,synchronize
的括号里面的对象可以是任意一个Object
对象,这里的 lock
对象并不一定是public static final
的,但是必须保证的是,多个线程在访问同一共享资源时使用的是同一个锁对象,即唯一的锁对象,才有意义。
比如,像下面这样写是完全没有意义的:
public class Foo {
public void f1() {
Object lock = new Object();//局部变量作为锁
synchronized (lock) {
// 代码段 A
// 访问共享资源 resource1
// 需要同步
}
}
}
这段代码使用的锁对象是方法内部的局部变量,这样每调用一次该方法就会生成一个新的锁对象,导致最终在不同线程中调用该方法使用的是不同的锁对象,它们之间根本就不会产生竞争,完全达不到锁的目的。除非我们将该对象改成公用的唯一值。
注意,竞争同步锁失败的线程进入的是该同步锁的就绪(Ready)队列,而不是等待队列,就绪队列里面的线程总是时刻准备着竞争同步锁,时刻准备着运行。而等待队列里面的线程则只能一直等待,直到等到某个信号的通知之后,才能够转移到就绪队列中,准备运行。
成功获取同步锁的线程,执行完同步代码段之后,会释放同步锁。该同步锁的就绪队列中的其他线程就继续下一轮同步锁的竞争。成功者就可以继续运行,失败者继续待在就绪队列中。
线程的等待 & 唤醒机制 wait & notify
synchronized
代码块中可以使用wait & notify
方法实现线程的等待和唤醒机制,又称线程间的通信,但是我觉得描述为等待和唤醒更加准确,因为线程通信是可以发送携带消息的,而wait & notify
只是发一个通知并不能携带额外的信息。
Object obj = new Object();
public void f1() {
synchronized(obj){
while(condition) {
...
obj.wait(); //进入等待状态,释放同步锁,直到有人唤醒它
...
}
}
}
public void f1() {
synchronized(obj){
...
obj.notify(); //唤醒等待obj的线程,当前线程继续执行
...
}
}
方法 | 说明 |
---|---|
wait() | 释放当前线程的同步锁,使当前线程进入等待状态,直到有其他线程调用notify()或notifyAll()唤醒 |
notify() | 通知唤醒等待该对象的线程,使该线程从等待状态进入可运行状态,当前线程继续执行,被唤醒的线程不一定立马运行,这取决于当前调用notify的线程是否执行完了synchronized代码块释放了同步锁。如果有多个线程在等待该对象,那么由操作系统指定该执行哪一个。 |
notifyAll() | 唤醒所有等待该对象的线程,哪一个线程先执行取决于系统实现 |
wait
和sleep
的区别:wait
和notify
只能在synchronized
代码块中调用,否则会抛异常,sleep
可以在任何地方调用。wait
进入等待的线程必须由notify
来唤醒,而sleep
等待的线程到指定时间会自动唤醒执行。wait
和sleep
都能被中断,响应中断异常。
synchronized实现原理:
每个Java对象可以作为同步锁是通过一个 内置锁 或者 监视器锁(Monitor Lock) 的指令来实现的。
java每个对象都有一个对象头,对象头中有个部分就是用来存储synchronized关键字锁的。
当线程访问一个同步代码块或者方法时必须得到这把锁,当退出或者抛出异常时会释放这把锁,进入锁释放锁是基于monitor对象来实现同步的,monitor对象主要有两个指令monitorenter(插入到同步代码开始位置)、monitorexit(插入到同步代码结束的时候),JVM会保证每个enter后有exit与之对应,但是可能会有多个exit和同一个enter对应,因为退出的时机不仅仅是方法退出也可能是方法抛出异常。每个对象都有一个monitor与之关联,一旦持有之后,就会处于锁定状态,当线程执行到monitorenter这个指令时,就会尝试获取该对象monitor所有权(尝试获取该对象锁)。
monitorenter和monitorexit指令
监视器锁的原理是通过计数器来实现,通过monitorenter
和monitorexit
指令会在执行的时候让锁计数减1
或者加1
,每个对象都与一个monitor
对象关联,一个monitor
的lock
锁只能被一个线程在同一时间获得,一个线程在尝试获得与这个对象关联的monitor
所有权时只会发生以下三种情况之一:
- 如果这个
monitor
计数器为0
,意味着目前还没有被获得,然后这个线程会立刻获得然后将计数器加1
, 其它线程就会知道该monitor
已经被持有了,这就是成功获得锁。 - 如果这个线程已经拿到了
monitor
的所有权,又重入了,计数器就会再加1
变成2、3…(可重入原理) - 如果
monitor
已经被其它线程持有了,现在去获取就会得到无法获取信号,那么就会进入阻塞状态,直到monitor
计数器变为0再去尝试获取锁。
monitorexit
就是对锁进行释放,释放的过程就是将计数器减1
,如果减完之后计数器变为0就意味着当前线程不再拥有对锁的所有权了。如果减完之后不为0就意味着是重入进来的,那么就继续持有该锁。
monitorenter
和monitorexit
指令可以在反编译的java class文件中对应的同步代码块位置查找到。
总结synchronized
注意事项:
- 无论
synchronized
关键字加在方法上还是对象上,它取得的锁都是对象,而不是把一段代码或函数当作锁。 - 每个对象只有一个锁(
lock
)与之相关联。 Synchronized
修饰的方法或代码块执行完毕后,同步锁是自动释放的,但是在执行同步代码块的过程中,遇到异常而导致线程终止,锁也会被释放。- 在执行同步代码块的过程中,执行了锁所属对象的
wait()
方法,这个线程会释放对象锁,进入对象的等待池。 - 当一个线程开始执行同步代码块时,并不意味着必须以不间断的方式运行,进入同步代码块的线程可以执行
Thread.sleep()
或执行Thread.yield()
方法,此时它并不释放对象锁,只是把运行的机会让给其他的线程。 synchronized
声明不会被继承,如果一个用synchronized
修饰的方法被子类覆盖,那么子类中这个方法不再保持同步,除非用synchronized
修饰。- 加锁对象不能为空,锁是保存在对象中的,为空自然不行。
synchronized缺陷:
- 不够灵活,不能手动释放锁,一旦加锁成功就必须一直等待同步代码块全部执行完毕,或者抛出异常。
- 不能中断正在加锁的线程, 相比于Lock
- 不能获取申请锁的结果是否成功,相比于Lock
- 不能做到读写锁分离
ReentrantLock 重入锁
ReentrantLock
是Lock
接口的实现类,是同步锁,可重入锁,可重入锁是指支持一个线程内对资源的重复加锁,也就是可以多次调用lock()
方法获取锁而不被阻塞,synchronized
本身也是可重入锁,只不过ReentrantLock
比synchronized
控制更灵活更具可操作性,能更加有效的避免死锁。同时ReentrantLock
还支持获取锁的公平性和非公平性。
可重入锁的示例:
void methodA(){
lock.lock(); // 获取锁
methodB();
lock.unlock() // 释放锁
}
void methodB(){
lock.lock(); // 获取锁
// 其他业务
lock.unlock();// 释放锁
}
public void doWork(){
lock.lock()
doWork();//递归调用,使得统一线程多次获得锁
lock.unLock()
}
ReentrantLock
内部采用的是计数功能,当一个线程内调用lock
方法时,计数器会设置为1,假如内部某个时刻再次申请时没有其他线程持有该锁,那么计数器加1,每当调用unlock方法时计数器减1。
方法 | 说明 |
---|---|
void lock() | 获取锁 |
void lockInterruptibly() throws InterruptedException | 获取锁,并且在获取锁成功后可响应中断操作 |
boolean tryLock() | 尝试非阻塞式获取锁,调用该方法后立即返回能否获取到锁,如果能申请返回true, 否则返回false |
boolean tryLock(long time, TimeUnit unit) throws InterruptedException | 尝试在超时时间内获取锁,如果超时未获取到返回false,否则返回true, 并且在该时间内支持中断操作 |
void unlock() | 释放锁 |
Condition newCondition() | 生成绑定到加锁对象的子条件实例,在lock成功的前提下,该类可以通过Condition#await() 或Condition#signal() 来等待或唤醒线程,实现更精细的控制 |
ReentrantLock
的构造函数为 ReentrantLock(boolean fair)
, 这里的布尔参数fair
是用来设置公平锁还是非公平锁,区别主要取决于申请时间上:
-
公平锁: 操作会排一个队按顺序执行,来保证执行顺序。所有进入阻塞的线程排队依次均有机会执行。
-
不公平锁: 是无序状态允许插队,
jvm
会自动计算如何处理更快速来调度插队。避免每一个线程都进入阻塞,再唤醒,性能高。因为线程可以插队,导致队列中可能会存在线程饿死的情况,一直得不到锁,一直得不到执行。
ReentrantLock lock = new ReentrantLock(); //参数默认false,不公平锁
ReentrantLock lock = new ReentrantLock(true); //公平锁
lock.lock();
try {
//操作
} finally {
lock.unlock(); //释放锁
}
// 等价于synchronized关键字
// public synchronized void method {
//
// }
jdk推荐将 unlock()
方法写在 finally
当中,这样能确保即便操作中发生异常也能最终释放锁。很显然与synchronized
相比,synchronized
的优势是加锁和释放锁都是自动处理的,不需要开发者手动操作。
支持响应中断:
ReentrantLock lock = new ReentrantLock(true); //公平锁
try {
lock.lockInterruptibly();//获取锁, 中断
//操作
} catch (InterruptedException e) {
Thread.currentThread().interrupt();//出现异常就中断
} finally {
lock.unlock();//释放锁
}
防止重复执行(忽略重复触发):
ReentrantLock lock = new ReentrantLock();
if (lock.tryLock()) { //如果已经被lock,则立即返回false不会等待,达到忽略操作的效果
try {
//操作
} finally {
lock.unlock();
}
} else {
//申请失败操作
}
lock.tryLock()
是非阻塞的方法,即立马返回能否获取到锁的结果,这样提高了程序执行的性能。
支持超时等待&中断:
ReentrantLock lock = new ReentrantLock(true); //公平锁
try {
//尝试5s内获取锁,如果5s后仍然无法获得锁则返回false继续执行
if (lock.tryLock(5, TimeUnit.SECONDS)) {
try {
//操作
} finally {
lock.unlock();
}
}
} catch (InterruptedException e) {
e.printStackTrace(); //当前线程5s内被中断(interrupt),会抛InterruptedException
}
newCondition子条件控制:
ReentrantLock lock = new ReentrantLock();
Condition worker1 = lock.newCondition();
Condition worker2 = lock.newCondition();
class Worker1 {
.....
worker1.await()//进入阻塞,等待唤醒
.....
}
class Worker2 {
.....
worker2.await()//进入阻塞,等待唤醒
.....
}
class Boss {
if(...) {
worker1.signal()//指定唤醒线程1
} else {
worker2.signal()//指定唤醒线程2
}
}
Condition的作用是对锁进行更精确的控制,可使用它的await-singnal
指定唤醒一个(组)线程。相比于Object
的wait-notify
要么全部唤醒,要么只能唤醒一个,更加灵活可控。
使用示例:
public class ProducerConsumerTest {
private Lock lock = new ReentrantLock();
/** 子条件控制 */
private Condition addCondition = lock.newCondition();
private Condition removeCondition = lock.newCondition();
private LinkedList<Integer> resources = new LinkedList<>();
private int maxSize;
public ProducerConsumerTest(int maxSize) {
this.maxSize = maxSize;
}
/** 生产者 */
public class Producer implements Runnable {
private int proSize;
private Producer(int proSize) {
this.proSize = proSize;
}
@Override
public void run() {
lock.lock();
try {
for (int i = 1; i < proSize; i++) {
while (resources.size() >= maxSize) {
System.out.println("当前仓库已满,等待消费...");
try {
// 生产者进入阻塞状态,等待消费者唤醒
addCondition.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("已经生产产品数: " + i + "\\t现仓储量总量:" + resources.size());
resources.add(i);
//唤醒消费者
removeCondition.signal();
}
} finally {
lock.unlock();
}
}
}
/** 消费者 */
public class Consumer implements Runnable {
@Override
public void run() {
String threadName = Thread.currentThread().getName();
while (true) {
lock.lock();
try {
while (resources.size() <= 0) {
System.out.println(threadName + " 当前仓库没有产品,请稍等...");
try {
// 消费者进入阻塞状态,等待生产者唤醒
removeCondition.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 消费数据
int size = resources.size();
for (int i = 0; i < size; i++) {
Integer remove = resources.remove();
System.out.println(threadName + " 当前消费产品编号为:" + remove);
}
// 唤醒生产者
addCondition.signal();
} finally {
lock.unlock();
}
}
}
}
public void test() {
new Thread(new Producer(100), "producer").start();
TimeUnit.SECONDS.sleep(2);
new Thread(new Consumer(), "consumer").start();
}
public static void main(String[] args) throws InterruptedException {
new ProducerConsumerTest(10).test();
}
}
与 synchronized
相比 ReentrantLock
最大的优势就是能获取申请锁的结果是成功还是失败,并且申请锁的过程中支持中断,加锁以后可以主动退出(unlock
)。
volatile 线程可见
volatile
关键字修饰的成员变量可以保证在不同线程之间的可见性,即一个线程修改了某个变量的值,新的值对其他线程来说是立即可见的。
CPU高速缓存:
计算机在执行程序时,每条指令都是在CPU中执行的,而执行指令过程中,势必涉及到数据的读取和写入。由于程序运行过程中的临时数据是存放在主存(物理内存)当中的,这时就存在一个问题,由于CPU执行速度很快,而从内存读取数据和向内存写入数据的过程跟CPU执行指令的速度比起来要慢的多,因此如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存。
当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。
在多核CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存,如果一个变量是被多个线程同时访问的共享变量,该变量就会同时存在于不同CPU缓存当中,就可能存在缓存不一致的问题。
因此为了解决缓存一致性问题,有两种方案:一种是加同步锁, 另一种是通过缓存一致性协议(MESI)。由于加锁会导致锁住CPU的过程中其他CPU无法访问该内存,性能较差,因此出现了缓存一致性协议方案:
当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
Java内存模型(JMM)规定所有的变量都是存在主存当中(类似于前面说的物理内存),每个线程都有自己的工作内存(类似于前面的高速缓存)。线程对变量的所有操作都必须在工作内存中进行,而不能直接对主存进行操作。因此Java内存当中也存在缓存一致性问题,而volatile
关键字就是Java用来解决缓存一致性问题的方案。
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile
修饰之后,那么就具备了两层语义:
- 可见性:保证在不同线程间可见,当变量在每次被线程访问时,都强迫从共享内存重新读取该成员的值,当成员变量值每次发生变化时,又强迫将其变化的值重新写入共享内存。
- 有序性:禁止指令重排序。
注意volatile
不能保证原子性,即不能解决非原子操作的线程安全。它的性能不及原子类高。
关于原子性、可见性、有序性
以上是关于Android中的线程线程安全 & 线程同步的主要内容,如果未能解决你的问题,请参考以下文章