线程安全
在并发编程学习过程中,我们应该都听过“线程安全”这个名称,对于这一概念,我们知道它可以解决并发编程不安全的问题,也有一个简单的印象:“代码在并发环境下,可以安全地被多个线程使用,这就是线程安全“。上述关于“线程安全”的认识大致是对的,我们来看看别人是如何定义“线程安全”的。
《Java并发编程实战(Java Concurrency In Practice) 》 的作者 Brian Goetz 为“线程安全”做出了一个比较恰当的定义: “当多个线程同时访问一个对象时, 如果不用考虑这些线程在运行时环境下的调度和交替执行, 也不需要进行额外的同步, 或者在调用方进行任何其他的协调操作, 调用这个对象的行为都可以获得正确的结果, 那就称这个对象是线程安全的。 ”
synchronized实现线程安全
互斥同步(Mutual Exclusion & Synchronization) 是一种最常见也是最主要的并发正确性保障手段。 同步是指在多个线程并发访问共享数据时, 保证共享数据在同一个时刻只被一条(或者是一些,当使用信号量的时候) 线程使用。 而互斥是实现同步的一种手段, 临界区(Critical Section) 、 互斥量(Mutex) 和信号量(Semaphore) 都是常见的互斥实现方式。
在 Java 里面, 最基本的互斥同步手段就是 synchronized 关键字, 这是一种块结构(Block Structured) 的同步语法。它解决的是多个线程之间访问资源的同步性,synchronized 关键字可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。 在《Java并发编程Bug的源头》一文中介绍的三个问题,synchronized 关键字都可以顺利地应对,即保证了原子性、可见性和有序性,相较于 volatile 关键字功能更加强大,本文将对该关键字进行深入学习。
synchronized实现方式
synchronized 关键字来实现加锁,注意要搞清楚被锁的资源(操作代码块)和锁,锁可以分为三种:
- 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁
- 修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁
- 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
synchronized 关键字加到静态方法和 synchronized(class)代码块上都是给 Class 类上锁。synchronized 关键字加到实例方法上是给对象实例上锁。尽量不要使用 synchronized(String a) ,因为 JVM 中,字符串常量池具有缓存功能!
synchronized作用于实例方法
所谓的实例对象锁就是用 synchronized 修饰实例对象中的实例方法,注意是实例方法不包括静态方法,如下
public class SynchronizedAddTest {
static int count = 0;
public static void main(String[] args) {
SynchronizedAddTest obj = new SynchronizedAddTest();
Thread t1 = new Thread(() -> {
obj.add();
}, "A");
Thread t2 = new Thread(() -> {
obj.add();
}, "B");
t1.start();
t2.start();
try {
t1.join();
t2.join();
System.out.println("main线程输出结果为==>" + count);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public synchronized void add() {
for (int i = 0; i < 100000; i++) {
count++;
}
}
}
复制代码
上述代码在深入学习volatile一文中有过类似案例,不过使用 volatile 关键字是无法保证原子性的,所以最终的结果可能不是 20万,而且每次运行结果都不一样,总是小于 20万。而加了 synchronized 关键字后,一定可以保证结果为 20万。
自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:
假如某个时刻变量 count 的值为10,
线程A对变量进行自增操作,线程A先读取了变量 count 的原始值,然后线程A被阻塞了(可能存在的情况);
然后线程B对变量进行自增操作,线程B也去读取变量 count 的原始值,由于线程A只是对变量 count 进行读取操作,而没有对变量进行修改操作,所以主存中 count 的值未发生改变,此时线程B会直接去主存读取 count 的值,发现 count 的值为10,然后进行加1操作,并把11写入工作内存,最后写入主存。
然后线程A接着进行加1操作,由于已经读取了 count 的值,注意此时在线程A的工作内存中 count 的值仍然为10,所以线程A对 count 进行加1操作后 count 的值为11,然后将11写入工作内存,最后写入主存。
那么两个线程分别进行了一次自增操作后,inc只增加了1。
加了 synchronized 关键字后,上述情况就会有所变化:
假如某个时刻变量 count 的值为10,
线程A对变量进行自增操作,首先要获取实例对象的锁,然后读值,进行加1操作,即使此时线程A被阻塞了,但不会主动释放锁。
那么线程B想要进行自增操作,就无法获取该实例对象的锁,所以就无法进行自增操作,只能等待线程A执行完毕,释放锁后,线程B才可以获取锁,然后进行自增操作。
线程A在释放锁之前是会将更新后的值写入到主存中,所以线程B就可以拿到最新的 count 值。
需要注意的是:如果是一个线程 A 需要访问实例对象 obj1 的 synchronized 方法 f1(当前对象锁是obj1),另一个线程 B 需要访问实例对象 obj2 的 synchronized 方法 f2(当前对象锁是obj2),这样是允许的,因为两个实例对象锁并不同相同,此时如果两个线程操作数据并非共享的,线程安全是有保障的,遗憾的是如果两个线程操作的是共享数据,那么线程安全就有可能无法保证了,如下代码将演示出该现象。
SynchronizedAddTest obj1 = new SynchronizedAddTest();
SynchronizedAddTest obj2 = new SynchronizedAddTest();
Thread t1 = new Thread(() -> {
obj1.add();
}, "A");
Thread t2 = new Thread(() -> {
obj2.add();
}, "B");
//输出结果为:
main线程输出结果为==>111538
复制代码
虽然我们使用 synchronized 修饰了 increase 方法,但却 new 了两个不同的实例对象,这也就意味着存在着两个不同的实例对象锁,因此t1和t2都会进入各自的对象锁,也就是说t1和t2线程使用的是不同的锁,因此线程安全是无法保证的。
解决这种困境的的方式是将 synchronized 作用于静态的 add 方法,这样的话,对象锁就当前类对象,由于无论创建多少个实例对象,但对于的类对象拥有只有一个,所有在这样的情况下对象锁就是唯一的。下面我们看看如何使用将 synchronized 作用于静态的 add 方法。
synchronized作用于静态方法
当 synchronized 作用于静态方法时,其锁就是当前类的class对象锁。由于静态成员不专属于任何一个实例对象,是类成员,因此通过class 对象锁可以控制静态成员的并发操作。
public class SynchronizedAddTest {
static int count = 0;
public static void main(String[] args) {
SynchronizedAddTest obj1 = new SynchronizedAddTest();
SynchronizedAddTest obj2 = new SynchronizedAddTest();
Thread t1 = new Thread(() -> {
obj1.add();
}, "A");
Thread t2 = new Thread(() -> {
obj2.add();
}, "B");
t1.start();
t2.start();
try {
t1.join();
t2.join();
System.out.println("main线程输出结果为==>" + count);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static synchronized void add() {
for (int i = 0; i < 100000; i++) {
count++;
}
}
}
//
main线程输出结果为==>200000
复制代码
需要注意的是,如果一个线程A调用一个实例对象的非 static synchronized 方法,而线程B需要调用这个实例对象所属类的静态 synchronized 方法,这是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类对象的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。