这就是我们上一篇中代码提到的加锁的主要方式,本质上是调用系统api进行加锁,系统api本质是靠cpu特定指令加锁.
synchronize的特性
互斥性
synchronized会起到互斥效果,某个线程执行到某个对象的synchronized中时,,其它线程如果也执行到同一个对象synchronized就会阻塞等待(锁冲突/锁竞争)
进入synchronized修饰的代码块,相当于加锁.
退出synchronized修饰的代码块,相当于解锁.
让我们回顾一下上一篇中这一段代码:
synchronized (locker) {//locker是锁对象,后面会讲
count++;
}
进入代码块内部(第一个大括号),相当于针对当前对象加锁.
执行完毕(出第二个大括号)相当于针对当前对象"解锁" .
让我们在多线程的场景下分析一下这个过程.
通过锁竞争可以让第二个线程指令无法插入到第一个线程指令中间,但此时第一个线程仍可被调度cpu.
上述过程就可以看作不同的人(线程)排队上厕所,一个人进去就得上锁,这时其它人进不去,直到那个人开了锁才可以.
理解"阻塞等待"
针对每一把锁,操作系统内部都维护了一个等待队列.当这个锁被某个线程占用的时候,其它线程尝试进行加锁,就加不上了,就会阻塞等待,一直等到之前的线程解锁之后,由操作系统唤醒一个新的线程,再来获取到这个锁.
注意:
上一个线程解锁之后,下一个线程并不是就能够立即获取到锁.而是要靠操作系统来"唤醒".这也就是操作系统线程调度的一部分工作.
假设有A B C三个线程,线程A先获取到锁,然后B尝试获得锁,然后C尝试获得锁,此时B和C都在阻塞队列中排队等待.但是A释放锁之后,虽然B比C先来的,但是B不一定能获取到锁,而是和C重新竞争,并不遵守先来后到的规则.
利用锁确实对多线程执行效率有影响,但这样仍会比串行执行快,因为锁以外的的内容仍然是并发执行的
可重入
定义:synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;
因此Java不会出现锁死问题,但锁死的内容仍需要了解
理解"把自己锁死"
一个线程没有释放锁,然后又尝试加锁.
//第一次加锁,加锁成功
lock();
//第二次加锁,锁已经被占用,阻塞等待.
lock();
按照之前锁的设定,第二次加锁的时候,就会阻塞等待.直到第一次的锁被释放,才能获取到第二个锁.但是释放第一个锁也是由该线程来完成,结果这个线程已经躺平了,啥都不想干了,就无法进行解锁操作.这时候就会死锁.
死锁的三种典型场景
1.一个线程一把锁:如果锁不是可重入锁.并且一个线程对这把锁2次就会出现死锁(把钥匙锁在屋里了).
public class TestLock {
public static Object locker = new Object();
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for(int i = 0; i < 50000; i++) {
synchronized (locker) {
synchronized (locker) {
count++;
}
}
}
});
Thread t2 = new Thread(() -> {
for(int i = 0; i < 50000; i++) {
synchronized (locker) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(count);
}
}
这里的t1按理来说是死锁的类型,不过synchronized是可重入锁,所以可以正常执行.
2.两个线程两把锁:线程1获取到锁A,线程2获取到锁B,接下来线程1尝试获取到锁B,线程2尝试获取到锁A就会导致死锁.(房子的钥匙锁车里了,车钥匙锁房子里了).
public class TestLock2 {
public static Object A = new Object(), B = new Object();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (A) {
//sleep一下,是给t2时间,让t2也能拿到B
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//尝试获取B,并没有释放A
synchronized (B) {
System.out.println("t1拿到了两把锁");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (B) {
//sleep一下,是给t1时间,让t1能拿到A
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//尝试获取A,并没有释放B
synchronized (A) {
System.out.println("t2 拿到了两把锁");
}
}
});
t1.start();
t2.start();
}
}
因此,形如这样的代码不会执行到第二次获取锁内的内容,通过jconsole可以观察到原因.
可见,两个线程都卡在了获取对方已获取得到 的锁的地方,而且状态为BLOCKED.
不过在这种情况下,仍可以通过约定加锁顺序来解决问题.
3.n个线程可以获取到m把锁. (建议看一下哲学家吃饭问题,这里就不过多讲解了).
死锁的四个必要条件(以下的条件缺一不可,缺一个不构成死锁)
1.互斥使用:获取锁的过程是互斥的.一个线程拿到了这把锁,另一个线程也想获取,就需要阻塞等待.(锁的基本特性,不好破坏)
2.不可抢占:一个线程拿到锁之后,只能主动解锁,不能让别叠对象把锁强行抢走(锁的基本特性,也不好破坏)
3.请求保持:一个线程拿到锁A之后,在持有A的条件下,尝试获取B(代码结构,看实际需求)
4.循环等待(环路等待):一个想获取到另一个的锁,另一个又在等其它的.(是最容易破坏的:指定一定规则,可避免循环等待->比如指定加锁顺序)
解决死锁的方案
(1)引入一个额外的锁
(2)去掉一个线程
(3)引入计数器,限制同时工作的线程数
(4)前面三个方案普适性不高,还是建议这个:引入加锁规则
以下面的代码为例,让我们分析一下synchronized的可重入性.
在可重入锁的内部,包含着"线程持有者"和"计数器"两个信息.
如果某个线程加锁的时候,发现锁已经被人占用,但是恰好占用的正是自己,那么就可以继续获取到锁,让计数器自增.(真正加锁,同时给计数器+1(初始为0,加锁之后变成1了,说明当前这个对象被该线程加锁一次),同时记录线程是谁,解锁时把count--,直到count减到零,才算是真正的解锁了)
synchronized使用实例
synchronized本质上要修改指定对象的"对象头".从使用角度来看,synchronized也一定要搭配一个具体的对象使用.
修饰代码块
明确指定锁哪个对象(比较常用的方法).
锁任意对象:
public class TestSynchronizedDemo {
private Object locker = new Object();
public void method() {
Synchronized (locker) {
}
}
}
锁当前对象:
public class TestSynchronizedDemo {
public void method() {
synchronized(this) {
//需要理解好这里是不是同一个对象
}
}
}
直接修饰普通方法
锁的TestSynchronizedDemo对象
public class TestSynchronizedDemo {
public synchronized void method() {
//相当于给this加锁(锁对象this)
}
}
修饰静态方法(不常见)
锁的TestSynchronizedDemo类对象(就如果synchronized是加到static方法上,相当于给类加锁).
public class TestSynchronized {
public synchronized static void method() {
}
}
我们要重点理解,synchronized锁的是什么.两个线程竞争同一把锁,才会产生阻塞等待
两个线程分别尝试获取两把不同的锁,不会产生锁竞争.
Java标准库中的线程安全类
Java标准库中很多线程都是不安全的.这些类可能涉及多线程修改共享数据,也没有任何加锁措施
比如:ArrayList,LinkedList,HashMap,TreeMap,HashSet,TreeSet,StringBuilder
但还是有一些是线程安全的.使用了一些锁机制来控制.
Vector(不推荐使用),HashTable(不推荐使用),ConcurrentHashMap,StringBuffer(不推荐使用)
还有的是没有加锁,但因为不涉及修改的特殊类,也是线程安全的.
String