java 并发——内置锁
坚持学习,总会有一些不一样的东西。
一、由单例模式引入
引用一下百度百科的定义——
线程安全是多线程编程时的计算机程序代码中的一个概念。在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。
文字定义总是很含糊,举个反例就很清楚了,想起之前总结过单例模式,就从单例模式开始吧。如果不清楚单例模式的新同学,可以看一下这篇总结:
java中全面的单例模式多种实现方式总结
单例模式中,懒汉式的实现方案如下:
public class Singleton {
private Singleton() {
}
private static Singleton sSingleton;
public static Singleton getInstance() {
if (sSingleton == null) {
sSingleton = new Singleton();
}
return sSingleton;
}
}
该方法在单线程中运行是没有问题的,但是在多线程中,某些情况下,多个线程同时都判断到 sSingleton == null
,然后又都执行 sSingleton = new Singleton()
,这样就不能保证单例了,我们说它不是线程安全的。
一种改进方法:
public class Singleton {
private Singleton() {
}
private static Singleton sSingleton;
public synchronized static Singleton getInstance() {
if (sSingleton == null) {
sSingleton = new Singleton();
}
return sSingleton;
}
}
上面这种实现,实际上效率非常低,是完全不推荐使用的。主要是因为加了 sychronized
关键字,意为同步的,也就是内置锁。使用 synchronized
关键字修饰方法, 是对该方法加锁,这样在同一时刻,只有一个线程能进入该方法,这样保证了线程安全,但是也正因为如此,效率变得很低,因为当对象创建之后,再次调用该方法的时候,直接使用对象就可以了,无需再同步了。于是有了下面改进的实现方式—— DCL(双重检查锁):
public class Singleton {
private Singleton() {
}
/**
* volatile is since JDK5
*/
private static volatile Singleton sSingleton;
public static Singleton getInstance() {
if (sSingleton == null) {
synchronized (Singleton.class) {
// 未初始化,则初始instance变量
if (sSingleton == null) {
sSingleton = new Singleton();
}
}
}
return sSingleton;
}
}
sSingleton = new Singleton()
不是一个原子操作。故须加 volatile 关键字修饰,该关键字在 jdk1.5 之后版本才有。下面就来说说 synchronized
和 volatile
这两个关键字。
二、同步与 synchronized
synchronized 锁代码块
java 提供了一种一种内置锁,来实现同步代码块,同步代码块包含两个部分:一个作为锁的对象引用,一个由锁保护的代码块。形式如下:
synchronized (lock) {
// 由锁保护的代码块
}
每个 java 对象都可以作为实现同步的锁,java 的内置锁也称为互斥锁。同一时刻只能有一个线程获得该锁,获得该锁的线程才能进入由锁保护的代码块,其它线程只能等待该线程执行完代码块之后,释放该锁后,再去获得该锁。例子:
public class SynchronizedDemo1 {
private Object lock = new Object();
public static void main(String[] args) {
SynchronizedDemo1 demo = new SynchronizedDemo1();
new Thread(() -> {
try {
demo.test1();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
try {
demo.test1();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public void test1() throws InterruptedException {
System.out.println("--- test1 begin - current thread: " + Thread.currentThread().getId());
Thread.sleep(1000);
synchronized (lock) {
System.out.println("--- test1 synchronized - current thread: " + Thread.currentThread().getId());
Thread.sleep(5000);
}
System.out.println("--- test1 end - current thread: " + Thread.currentThread().getId());
}
}
执行结果:
从结果可以清楚地看到一个线程进入同步代码块之后,另一个线程阻塞了,需要等到前者释放锁之后,它获得锁了才能进入同步代码块。
上面代码中,我们创建的 lock
对象作为锁。用 synchronized
修饰方法又是什么充当了锁呢?
synchronized 修饰方法
以关键字 synchronized
修饰的方法就是一种横跨整个方法的同步代码块,其中该同步代码块的锁就是调用该方法的对象。
class A {
public synchronized void a(){
System.out.println("hello");
}
}
等价于
class A {
public void a(){
synchronized(this) {
System.out.println("hello");
}
}
}
静态方法用 类名.方法名
来调用,以关键字 synchronized
修饰的静态方法则以 Class 对象作为锁。
class A {
public static synchronized void a(){
System.out.println("hello");
}
}
等价于
class A {
public static void a(){
synchronized(A.class) {
System.out.println("hello");
}
}
}
写个demo测试一下:
public class A {
public static void main(String[] args) {
A obj_a = new A();
new Thread() {
@Override
public void run() {
try {
obj_a.a();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}.start();
new Thread() {
@Override
public void run() {
try {
obj_a.b();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}.start();
new Thread(){
@Override
public void run() {
try {
A.c();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}.start();
try {
A.d();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public synchronized void a() throws InterruptedException {
System.out.println("--- begin a - current Thread " + Thread.currentThread().getId());
Thread.sleep(8000);
System.out.println("--- end a - current Thread " + Thread.currentThread().getId());
}
public synchronized void b() throws InterruptedException {
System.out.println("--- begin b - current Thread " + Thread.currentThread().getId());
Thread.sleep(8000);
System.out.println("--- end b - current Thread " + Thread.currentThread().getId());
}
public synchronized static void c() throws InterruptedException {
System.out.println("--- begin c - current Thread " + Thread.currentThread().getId());
Thread.sleep(5000);
System.out.println("--- end c - current Thread " + Thread.currentThread().getId());
}
public synchronized static void d() throws InterruptedException {
System.out.println("--- begin d - current Thread " + Thread.currentThread().getId());
Thread.sleep(5000);
System.out.println("--- end d - current Thread " + Thread.currentThread().getId());
}
}
运行结果如下:
可以看到,由于方法 a 和 方法 b 是同一个锁 obj_A
,所以当某个线程执行其中一个方法是,其他线程也不能执行另一个方法。但是方法 c 是由 A.class
对象锁住的,执行方法 C 的线程与另外两个线程没有互斥关系。
对于某个类的某个特定对象来说,该类中,所有 synchronized 修饰的非静态方法共享同一个锁,当在对象上调用其任意 synchronized 方法时,此对象都被加锁,此时,其他线程调用该对象上任意的 synchronized 方法只有等到前一个线程方法调用完毕并释放了锁之后才能被调用。
而对于一个类中,所有 synchronized 修饰的静态方法共享同一个锁。
可重入锁
当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞,然而,内置锁是可重入的,,如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。这也意味着获取锁的操作粒度是“线程”,而不是“调用”。当线程请求一个未被持有的锁时,jvm 将记下锁的持有者(即哪个线程),并获取该锁的计数值为 1 ,当同一个线程再次获取该锁时, jvm 将计数值递增,当线程退出同步代码块时,计数值将递减,当计数值为 0 时,将释放锁。
public class B {
public static void main(String[] args) {
B obj_B = new B();
obj_B.b();
}
private synchronized void a(){
System.out.println("---a");
}
private synchronized void b(){
System.out.println("---b");
a();
}
}
执行上面这段代码,将输出
---b
---a
假设没有可重入的锁,对于对象 obj_B
来说,调用 b 方法时,线程将会持有 obj_B
这个锁,在方法 b 中调用方法 a 时,将会一直等待方法 b 释放锁,造成死锁的情况。
《Java 并发编程实战》 中举的可重入锁的例子:
public class Widget {
public synchronized void doSomething() {
...
}
}
public class LoggingWidget extends Widget {
public synchronized void doSomething() {
System.out.println(toString() + ": calling doSomething");
super.doSomething();
}
}
我第一遍看这段代码的时候,在思考,这里父类子类的方法都有synchronized同步,当调用子类LoggingWidget的doSomething()时锁对象肯定是当时调用的那个LoggingWidget实例,可是问题是当执行到super.doSomething()时,要调用父类的同步方法,那此时锁对象是谁?是同一个锁进入了 2 次,还是获得了子类对象和父类对象的 2 个不同的锁?
下面这段代码能给出结论:
public class Test {
public static void main(String[] args) throws InterruptedException {
final TestChild t = new TestChild();
new Thread(new Runnable() {
@Override
public void run() {
t.doSomething();
}
}).start();
Thread.sleep(100);
t.doSomethingElse();
}
public synchronized void doSomething() {
System.out.println("something sleepy!");
try {
Thread.sleep(1000);
System.out.println("woke up!");
}
catch (InterruptedException e) {
e.printStackTrace();
}
}
private static class TestChild extends Test {
public void doSomething() {
super.doSomething();
}
public synchronized void doSomethingElse() {
System.out.println("something else");
}
}
}
这段代码输出了:
something sleepy!
woke up!
something else
而不是
something sleepy!
something else
woke up!
这说明是同一个锁进入了 2 次,即调用子类方法的子类对象。而这也正好符合多态的思想,调用 super.doSomething() 方法时,是子类对象调用父类方法。
三、同步关键字 volatile
原子性
原子是世界上的最小单位,具有不可分割性。 比如 a=0
这个操作不可分割,我们说这是一个原子操作。而 ++i
就不是一个原子操作。它包含了"读取-修改-写入"的操作。
同步代码块,可以视作是一个原子操作。Java从JDK 1.5开始提供了java.util.concurrent.atomic包,这个包中的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。比如:AtomicBoolean
AtomicInteger
AtomicLong
等原子操作类。具体可查阅JDK源码或者参考《ava并发编程的艺术》第7章。
下面说说复合操作与线程安全的问题。
我们知道,java 集合框架中的 Vector 类是线程安全的,查看该类的源码发现,很多关键方法都用了synchronized
加以修饰。但是实际使用时候,稍有不慎,你会发现,它可能并不是线程安全的。比如在某个类中拓展一下 Vector 的方法,往 vector 中添加一个元素时,先判断该元素是否存在,如果不存在才添加,该方法大概像下面这样:
public class CJUtil{
public void putElement(Vector<E> vector, E x){
boolean has = vector.contains(x);
if(!has){
vector.add(x);
}
}
}
上面这个代码肯定是线程不安全的,但是为什么呢?不是说好,Vector 类是线程安全的吗?上网搜了一下,居然发现关于 Vector 类是不是线程安全的存在争议,然后我看到有人说它不是线程安全的,给出的理由比如像上面这种先判断再添加,或者先判断再删除,是一种复合操作,然后认真地打开 JDK 的源码看了,发现 Vector 类中 contains
方法并没有用 synchronized
修饰,然后得出了结论,Vector不是线程安全的...
事实到底是怎样的呢?我们假设 Vector 类的 contains
也用 synchronized
关键字加锁同步了,此时有两个线程 tA 和 tB 同时访问这个方法,tA 调用到 contains
方法的时候,tB 阻塞, tA 执行完 contains
方法,返回 false 后,释放了锁,在 tA 执行 add
之前,tB 抢到了锁,执行了 contains
方法,tA 阻塞。对于同一个元素, tb 判断也不包含,后面, tA 和 tB 都向 Vector 添加了这个元素。经过分析,我们发现,对于上述复合操作线程不安全的原因,并非是其中单个操作没有加锁同步造成的。
那如何解决这个问题呢?可能马上会想到,给 putElement 方法加上 synchronized
同步。
public class CJUtil{
public synchronized void putElement(Vector<E> vector, E x){
boolean has = vector.contains(x);
if(!has){
vector.add(x);
}
}
}
这样整个方法视为一个原子操作,只有当 tA 执行完整个方法后,tB 才能进入,也就不存在上面说的问题了。其实,这只是假象。这种在加锁的方法,并不能保证线程安全。我们可以从两个方面来分析一下:
- 从上文我们知道,给方法加锁,锁对象,是调用该方法的对象。这和我们操作 Vector 方法的锁并不是同一个锁。我们虽然保证了只有一个线程能够进入到
putElement
方法去操作 vector,但是我们没法保证其它线程通过其它方法不去操作这个 vector 。 - 上一条中,只有一个线程能够进入到
putElement
方法,是不准确的,因为这个方法不是静态的,如果在两个线程中,分别用CJUtil
的两个不同的实例对象,是可以同时进入到putElement
方法的。
正确的做法应该是:
public class CJUtil{
public void putElement(Vector<E> vector, E x){
synchronized(vector){
boolean has = vector.contains(x);
if(!has){
vector.add(x);
}
}
}
}
重排序
重排序通常是编译器或运行时环境为了优化程序性能而采取的对指令进行重新排序执行的一种手段。重排序分为两类:编译器重排序和运行期重排序,分别对应编译时和运行时环境。
不要假设指令执行的顺序,因为根本无法预知不同线程之间的指令会以何种顺序执行。
编译器重排序的典型就是通过调整指令顺序,在不改变程序语义的前提下,尽可能的减少寄存器的读取、存储次数,充分复用寄存器的存储值。
int a = 5;①
int b = 10;②
int c = a + 1;③
假设用的同一个寄存器
这三条语句,如果按照顺序一致性,执行顺序为①②③寄存器要被读写三次;但为了降低重复读写的开销,编译器会交换第二和第三的位置,即执行顺序为①③②
可见性
可见性是一种复杂的属性,因为可见性中的错误总是会违背我们的直觉。通常,我们无法确保执行读操作的线程能适时地看到其他线程写入的值,有时甚至是根本不可能的事情。为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。
可见性,是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果。另一个线程马上就能看到。比如:用volatile修饰的变量,就会具有可见性。volatile修饰的变量不允许线程内部缓存和重排序,即直接修改内存。所以对其他线程是可见的。但是这里需要注意一个问题,volatile只能让被他修饰内容具有可见性,但不能保证它具有原子性。
下面这段代码:
public class A {
private static boolean flag = false;
public static void main(String[] args) {
new Thread() {
@Override
public void run() {
while (!flag) {
}
}
}.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
}
}
看执行结果:
可以看到程序并没有像我们所期待的那样,在一秒之后,退出,而是一直处于循环中。
下面给 flag
加上 volatile
关键修饰:
public class A {
private static volatile boolean flag = false;
public static void main(String[] args) {
new Thread() {
@Override
public void run() {
while (!flag) {
}
}
}.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
}
}
再看结果:
结果表明,没有用 volatile
修饰 flag
之前,改变了不具有可见性,一个线程将它的值改变后,另一个线程却 “不知道”,所以程序没有退出。当把变量声明为 volatile
类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile
变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的值。
在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比sychronized关键字更轻量级的同步机制。
当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到CPU缓存中。如果计算机有多个CPU,每个线程可能在不同的CPU上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中。
而声明变量是 volatile 的,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步。
volatile 修饰的遍历具有如下特性:
- 保证此变量对所有的线程的可见性,当一个线程修改了这个变量的值,volatile 保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。但普通变量做不到这点,普通变量的值在线程间传递均需要通过主内存(详见:Java内存模型)来完成。
- 禁止指令重排序优化。
- 不会阻塞线程。
synchronized 与可见性
细心的人应该发现了,上面代码中的循环是一个空循环,我试着去掉 volatile
关键字,在循环里面加了一条打印信息,如下:
public class A {
private static boolean flag = false;
public static void main(String[] args) {
new Thread() {
@Override
public void run() {
while (!flag) {
System.out.println("---");
}
}
}.start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
}
}
结果会是怎样,会一直打印 "---" 吗?看结果:
奇怪了,为什么没有使用 volatile
关键字,一秒之后程序也推出了。点击查看 System.out.println(String x)
的源码:
public void println(String x) {
synchronized (this) {
print(x);
newLine();
}
}
我们发现,该方法加锁同步了。
那么问题来了,synchronized 到底干了什么。。
按理说,synchronized 只会保证该同步块中的变量的可见性,发生变化后立即同步到主存,但是,flag 变量并不在同步块中,实际上,JVM对于现代的机器做了最大程度的优化,也就是说,最大程度的保障了线程和主存之间的及时的同步,也就是相当于虚拟机尽可能的帮我们加了个volatile,但是,当CPU被一直占用的时候,同步就会出现不及时,也就出现了后台线程一直不结束的情况。
参考书籍:
《Java 并发编程实战》
《Java 编程思想 第四版》