1 介绍
在并发编程中发生的最常见的一种情况是超过一个执行线程使用共享资源。在并发应用程序中,多个线程读或写相同的数据或访问同一文件或数据库连接这是正常的。这些共享资源会引发错误或数据不一致的情况,我们必须通过同步机制来避免这些错误。
解决这些问题从临界区的概念开始。临界区是访问一个共享资源在同一时间不能被超过一个线程执行的代码块。
Java(和几乎所有的编程语言)提供同步机制,帮助程序员实现临界区。当一个线程想要访问一个临界区,它使用其中的一个同步机制来找出是否有任何其他线程执行临界区。如果没有,这个线程就进入临界区。否则,这个线程通过同步机制暂停直到另一个线程执行完临界区。当多个线程正在等待一个线程完成执行的一个临界区,JVM选择其中一个线程执行,其余的线程会等待直到轮到它们。
这个系列的文章将逐一介绍Java提供的几个同步机制:Synchronized、Lock、Automic和Volatile。Synchronized可以标记以下四种类型的代码区域:
- Instance methods(普通方法)
- Static methods(静态方法)
- Code blocks inside instance methods(普通方法内的代码快)
- Code blocks inside static methods(静态方法内的代码快)
2 普通同步方法
下面是一个普通的同步方法。
1 | public synchronized void add(int value){ |
如果此时A线程和B线程同时对同一个对象的add方法访问,那么只能有一个线程(假设是A线程)能够进入方法体内获得锁资源,此时B线程只能等待A线程执行完方法体后(释放锁资源)才能执行add方法体中的代码。
总结一下就是,在同一时刻只能有一个线程能对同一个对象的synchronized方法进行访问。如果一个线程A正在执行一个synchronized方法,而线程B想要执行同个实例对象的非静态synchronized方法,它将阻塞,直到线程A执行完。但是如果线程B访问相同类的不同实例对象,它们都不会被阻塞。
再深入一点:
CPU从主内存中读数据的效率相对来说不高,现在主流的计算机中,都有几级缓存。每个线程读取共享变量时,都会将该变量加载进其对应CPU的高速缓存里,修改该变量后,CPU会立即更新该缓存,但并不一定会立即将其写回主内存(实际上写回主内存的时间不可预期)。此时其它线程(尤其是不在同一个CPU上执行的线程)访问该变量时,从主内存中读到的就是旧的数据,而非第一个线程更新后的数据。
3 静态同步方法
1 | public static synchronized void add(int value){ |
与普通同步方法不同,静态同步方法修饰的对象是整个class对象。只有一个执行线程能访问被synchronized关键字声明的静态方法,但另一个线程可以访问该类的一个对象中的其他非静态的方法。你必须非常小心这一点,因为两个线程可以访问两个不同的同步方法,如果其中一个是静态的而另一个不是。如果这两种方法改变相同的数据,你将会有数据不一致的错误。
4 普通方法中的同步块
1 | public void log2(String msg1, String msg2){ |
这样调用和普通同步方法实现的效果其实是一样的。注意到“this”字段是一个Monitor对象,它和普通同步方法中锁定的对象是一个概念。
小知识:
引用[维基百科][]的说法,monitor是一种线程安全的class,object或者module,即monitor允许多个线程同时访问其内的方法或者属性而不会出现二义性。
5 静态方法中的同步块
1 | public static void log2(String msg1, String msg2){ |
6 锁的分类
以上内容就是对Synchronized关键字使用和触发场景的介绍,下面再从宏观上介绍一下关于锁的分类:
6.1 偏向锁、轻量锁、重量锁
偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在接下来的运行过程中,该锁没有被其他的线程访问,则持有偏向锁的线程将永远不需要触发同步。
如果在运行过程中,遇到了其他线程抢占锁,JVM会尝试消除它身上的偏向锁,将锁恢复到标准的轻量级锁。偏向锁只能在单线程下起作用,主要解决无竞争下的锁性能问题。
我们看下无竞争下锁存在什么问题:
按照之前的HotSpot设计,每次加锁/解锁都会涉及到一些CAS操作(比如对等待队列的CAS操作),CAS操作会延迟本地调用,因此偏向锁的想法是一旦线程第一次获得了监视对象,之后让监视对象“偏向”这个线程,之后的多次调用则可以避免CAS操作,说白了就是置个变量,如果发现为true则无需再走各种加锁/解锁流程。
锁演变的流程是这样的:偏向锁->轻量级锁->重量级锁。
6.2 乐观锁与悲观锁
第一种理解方式:乐观锁就是对加锁对象比较乐观,假定它不存在很多并发更新请求。悲观锁反之。乐观锁严格的说应该叫 乐观并发控制(Optimistic concurrency control),这种控制方式一个特点就是一旦发现其他并发操作更新,会回退,并从新执行自己的流程。乐观锁思想的实现: CAS 。
第一种理解方式:乐观锁是一种思想,具体实现是,表中有一个版本字段,第一次读的时候,获取到这个字段。处理完业务逻辑开始更新的时候,需要再次查看该字段的值是否和第一次的一样。如果一样更新,反之拒绝。之所以叫乐观,因为这个模式没有从数据库加锁。悲观锁是数据库层面加锁,都会阻塞去等待锁。
6.3 锁的可重入性
7 线程通信(wait、notify、notifyAll)
当一个线程进入wait之后,就必须等其他线程notify/notifyall,使用notifyall,可以唤醒所有处于wait状态的线程,使其重新进入锁的争夺队列中,而notify只能唤醒一个。注意,任何时候只有一个线程可以获得锁,也就是说只有一个线程可以运行synchronized中的代码,notifyall只是让处于wait的线程重新拥有锁的争夺权,但是只会有一个获得锁并执行。
注意:
wait、notify、notifyAll必须都在同步块中调用,否者会抛出IllegalMonitorStateException异常。
7.1 深入了解notify与notifyAll的区别
先说两个概念:锁池和等待池。
锁池:假设线程A已经拥有了某个对象(注意:不是类)的锁,而其它的线程想要调用这个对象的某个synchronized方法(或者synchronized块),由于这些线程在进入对象的synchronized方法之前必须先获得该对象的锁的拥有权,但是该对象的锁目前正被线程A拥有,所以这些线程就进入了该对象的锁池中。
等待池:假设一个线程A调用了某个对象的wait()方法,线程A就会释放该对象的锁后,进入到了该对象的等待池中。
然后再来说notify和notifyAll的区别。
1、如果线程调用了对象的wait()方法,那么线程便会处于该对象的等待池中,等待池中的线程不会去竞争该对象的锁。
2、当有线程调用了对象的notifyAll()方法(唤醒所有wait线程或notify()方法(只随机唤醒一个 wait 线程),被唤醒的的线程便会进入该对象的锁池中,锁池中的线程会去竞争该对象锁。也就是说,调用了notify后只要一个线程会由等待池进入锁池,而notifyAll会将该对象等待池内的所有线程移动到锁池中,等待锁竞争。
3、优先级高的线程竞争到对象锁的概率大,假若某线程没有竞争到该对象锁,它还会留在锁池中,唯有线程再次调用wait()方法,它才会重新回到等待池中。而竞争到对象锁的线程则继续往下执行,直到执行完了synchronized代码块,它会释放掉该对象锁,这时锁池中的线程会继续竞争该对象锁。
综上,所谓唤醒线程,另一种解释可以说是将线程由等待池移动到锁池,notifyAll调用后,会将全部线程由等待池移到锁池,然后参与锁的竞争,竞争成功则继续执行,如果不成功则留在锁池等待锁被释放后再次参与竞争。而notify只会唤醒一个线程。
8 如何避免死锁
在多线程同步机制中,试想一下会不会出现线程1和线程2互相需要对方所持有的锁的情况呢?答案是肯定的,这就是死锁。我们要避免死锁,需从以下三个方面入手:
1.加锁顺序
2.加锁时限
3.死锁检测
8.1 加锁顺序
当多个线程需要相同的一些锁,但是按照不同的顺序加锁,死锁就很容易发生。如果能确保所有的线程都是按照相同的顺序获得锁,那么死锁就不会发生。
8.2 加锁时限
另外一个可以避免死锁的方法是在尝试获取锁的时候加一个超时时间,这也就意味着在尝试获取锁的过程中若超过了这个时限该线程则放弃对该锁请求。若一个线程没有在给定的时限内成功获得所有需要的锁,则会进行回退并释放所有已经获得的锁,然后等待一段随机的时间再重试。这段随机的等待时间让其它线程有机会尝试获取相同的这些锁,并且让该应用在没有获得锁的时候可以继续运行。
这种机制存在一个问题,在Java中不能对synchronized同步块设置超时时间。你需要创建一个自定义锁,或使用Java5中java.util.concurrent包下的工具。
8.3 死锁检测
死锁检测是一个更好的死锁预防机制,它主要是针对那些不可能实现按序加锁并且锁超时也不可行的场景。每当一个线程获得了锁,会在线程和锁相关的数据结构中(map、graph等等)将其记下。除此之外,每当有线程请求锁,也需要记录在这个数据结构中。当一个线程请求锁失败时,这个线程可以遍历锁的关系图看看是否有死锁发生。例如,线程A请求锁7,但是锁7这个时候被线程B持有,这时线程A就可以检查一下线程B是否已经请求了线程A当前所持有的锁。如果线程B确实有这样的请求,那么就是发生了死锁(线程A拥有锁1,请求锁7;线程B拥有锁7,请求锁1)。
那么当检测出死锁时,这些线程该做些什么呢?
一个可行的做法是释放所有锁,回退,并且等待一段随机的时间后重试。这个和简单的加锁超时类似,不一样的是只有死锁已经发生了才回退,而不会是因为加锁的请求超时了。虽然有回退和等待,但是如果有大量的线程竞争同一批锁,它们还是会重复地死锁。
一个更好的方案是给这些线程设置优先级,让一个(或几个)线程回退,剩下的线程就像没发生死锁一样继续保持着它们需要的锁。如果赋予这些线程的优先级是固定不变的,同一批线程总是会拥有更高的优先级。为避免这个问题,可以在死锁发生的时候设置随机的优先级。参考链接>>
以上的内容不是很具体,更加全面的解读了解请参考知乎的这篇文章,里面的协作对象之前发生的死锁场景以及解决方式我没看明白,不影响理解吧。同时文章中的Jstack工具使用请参考以下文章:
jstack使用
JAVA Thread Dump 分析综述
注意啦,以上两篇参考文章只是有参考意义,并不需要背,用到的时候来查看一下就行。
那么还有一个问题,jstack是面向java程序的啊,在Android中有没有类似的工具呢?有的,可以参考stackoverflow上这篇文章。总结一下就是有以下两种方式:
方式一
执行如下命令:
adb shell run-as com.google.android.colendar kill -3 2596
//2596 是程序进程ID,这个在locat的进程下拉列表中就可以看到。
之后在locat中会看如下的日志信息:
Traces for pid 2596 written to: /data/anr/trace_00
//意思就是dump文件保存到了冒号后面的目录了。
之后通过adb命令拉取到电脑系统目录就行了。
adb pull /data/anr/trace_00
可是问题是,这个命令需求root权限,而一般的手机又没有root,那么怎么办呢?其实大家有没有注意到官方给的模拟器中有些的playstore列的是空的,这些模拟器是可以拿到root权限的。运行完模拟器后,通过adb root命令进入root模式就可以愉快执行需要root权限才能执行的命令了。知识来源1,知识来源2。
方式二
在调试模式下,在底部调试窗口左边工具部分点击一个类似 照相机📷 的按钮就行了。
参考资料
1.并发编程网
2.Java Synchronized Blocks
3.java中的notify和notifyAll有什么区别?
4.维基百科