Fork me on GitHub

Java 线程总结(二)

Java 线程总结(二) —— synchronized 关键字

1、Java 中的每一个对象都可以作为锁。具体表现为以下三种形式:

对于普通同步方法,锁是当前实例对象。
对于静态同步方法,锁是当前类的 Class 对象。
对于同步代码块,锁是 synchronized 括号里配置的对象。

synchronized 可以用于修饰「普通方法、静态方法、代码块」。

2、synchronized 用于「实例方法」的时候?
synchronized 用于「实例方法」的时候到底做了什么呢?看上去,synchronized 使得同时只能有一个线程执行实例方法,但这个理解是不确切的。多个线程是可以同时执行同一个 synchronized 实例方法的,只要它们「访问的对象」是不同的。

synchronized 实例方法实际保护的是 同一个对象的方法调用,确保同时只能有一个线程执行。 再具体来说,synchronized 实例方法保护的是当前实例对象,即 this,this 对象有 一个锁和一个等待队列,锁只能被一个线程持有,其他试图获得同样锁的线程需要等待,加入到等待队列中。 执行 synchronized 实例方法的过程大概如下:

1.尝试获得锁,如果能够获得锁,继续下一步,否则加入等待队列,阻塞并等待唤醒。
2.执行实例方法体代码。
3.释放锁,如果等待队列上有等待的线程,从中取一个并唤醒,如果有多个等待的线程,唤醒哪一个是不一定的,不保证公平性。

当前线程不能获得锁的时候,它会加入等待队列等待,线程的状态会变为 BLOCKED。

3、synchronized 保护的是对象而非代码,只要访问的是同一个对象的 synchronized 方法,即使是不同的代码,也会被同步顺序访问。 synchronized 方法不能防止非 synchronized 方法被同时执行。一般在保护变量时,需要在所有访问该变量的方法上加上 synchronized。

4、synchronized 用于「静态方法」的时候?
前面我们说,synchronized 保护的是对象,对实例方法,保护的是当前实例对象 this,对静态方法,保护的是哪个对象呢?是类对象,即「类名称.class」,实际上,每个对象都有一个锁和一个等待队列,类对象也不例外。

synchronized 静态方法和 synchronized 实例方法保护的是不同的对象,不同的两个线程,可以同时,一个执行 synchronized 静态方法,另一个执行 synchronized 实例方法。

5、synchronized 用于「代码块」的时候?
synchronized 括号里面的就是保护的对象,对于实例方法,就是 this,{} 里面是同步执行的代码。synchronized 同步的对象可以是任意对象,任意对象都有一个锁和等待队列,或者说,任何对象都可以作为锁对象。

6、synchronized 之可重入性
synchronized 有一个重要的特征,它是可重入的,也就是说,对同一个执行线程,它在获得了锁之后,在调用其他需要 同样锁 的代码时,可以直接调用。

可重入是通过记录锁的持有线程和持有数量来实现的,当调用被 synchronized 保护的代码时,检查对象是否已被锁,如果是,再检查是否被当前线程锁定,如果是,增加持有数量,如果不是被当前线程锁定,才加入等待队列,当释放锁时,减少持有数量,当数量变为0时才释放整个锁。

7、synchronized 之内存可见性
synchronized 除了保证原子操作外,它还有一个重要的作用,就是保证内存可见性,在释放锁时,所有写入都会写回内存,而获得锁后,都会从内存中读最新数据。

不过,如果只是为了保证内存可见性,使用 synchronzied 的成本有点高,有一个更轻量级的方式,那就是给变量加修饰符 volatile。加了 volatile 之后,Java 会在操作对应变量时插入特殊的指令,保证读写到内存最新值,而非缓存的值。

8、synchronized 之解决死锁
使用 synchronized 或者其他锁,要注意死锁。关于死锁解决,首先,应该尽量避免在持有一个锁的同时去申请另一个锁,如果确实需要多个锁,所有代码都应该按照相同的顺序去申请锁。使用显式锁接口 Lock,它支持尝试获取锁和带时间限制的获取锁方法,使用这些方法 可以在获取不到锁的时候释放已经持有的锁,然后再次尝试获取锁或干脆放弃,以避免死锁。

一旦出现了死锁,Java 不会主动处理,借助一些工具,我们可以发现运行中的死锁。eg:Java 自带的 jstack 命令会报告发现的死锁。

9、同步容器
Collections 的一些方法,它们可以返回线程安全的同步容器,它们是给所有容器方法都加上 synchronized 来实现安全的。这里线程安全针对的是容器对象,指的是当多个线程并发访问同一个容器对象时,不需要额外的同步操作,也不会出现错误的结果。

但是,加了 synchronized,所有方法调用变成了原子操作,客户端在调用时,是不是就绝对安全了呢?不是的,至少有以下情况需要注意:

复合操作,比如先检查再更新。对于 putIfAbsent 方法,虽然 get、put 方法是同步的,但是可能有多个线程都执行了检查者一步,然后都调用 put 方法,破坏了 putIfAbsent 方法期望保持的语义。

伪同步,即同步了不同的对象。如果给 putIfAbsent 方法加上 synchronized 关键字,虽然 putIfAbsent 方法是同步方法了,但是,put 方法没有加呢,其他线程照样可以调用该方法进行 put,同步错对象了。解决办法是要么 EnhancedMap 中的所有方法都需要加上 synchronized,要么使用 map 作为锁。

迭代,对于同步容器对象,虽然单个操作是安全的,但迭代并不是。eg:创建一个同步 List 对象,一个线程修改 List,另一个遍历,会产生 ConcurrentModificationException 异常。如果在遍历的同时容器发生了结构性变化,就会抛出该异常,同步容器并没有解决这个问题,如果要避免这个异常,需要在遍历的时候给整个容器对象加锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class EnhancedMap <K, V> {
Map<K, V> map;

public EnhancedMap(Map<K,V> map){
this.map = Collections.synchronizedMap(map);
}

public V putIfAbsent(K key, V value){
V old = map.get(key);
if(old!=null){
return old;
}
// 注意这里的 put 方法是下面即本类中定义的 put 方法,不是 map 中的 put 方法
map.put(key, value);
return null;
}

public void put(K key, V value){
map.put(key, value);
}
}

11、并发容器
同步容器的性能是比较低的,当并发访问量比较大的时候性能很差。所幸的是,Java 中还有很多专为并发设计的容器类,比如:

CopyOnWriteArrayList
ConcurrentHashMap
ConcurrentLinkedQueue
ConcurrentSkipListSet

这些容器类都是线程安全的,但都没有使用 synchronized、没有迭代问题、直接支持一些复合操作、性能也高得多。

参考博客

Java编程的逻辑 - 理解 synchronized
synchronized 的可重入性

-------------本文结束感谢您的阅读-------------

本文标题:Java 线程总结(二)

文章作者:Yan ChongSheng

发布时间:2018年08月15日

最后更新:2018年08月29日

原始链接:yanchongsheng.github.io/2018/08/15/Java-Java-Thread-2018-08-15-Java线程总结-二/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

开启打赏模式