为什么需要同步
在大多数实际的多线程应用中,两个或两个以上线程需要共享对统一数据的存取。如果两个线程同时修改一个对象,可能就是导致数据不准确,使对象状态混乱,引起程序错误。因此加入同步锁以避免在该线程没有完成操作之前,被其他线程的调用,从而保证了该变量的唯一性和准确性。
代码示例:
1 | // 模拟三个人买票,一人买10张 |
如上,运行结果中有时会出现重复购买同一张票的情况,这就是线程不安全的表现。
七种解决办法
- 同步方法
有 synchronized 关键字修饰的方法。 由于 java 的每个对象都有一个内置锁,当用此关键字修饰方法时,内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。 - 同步代码块
有 synchronized 关键字修饰的语句块。 被该关键字修饰的语句块会自动被加上内置锁,从而实现同步。synchronized(this){/*区块*/}
,它的作用域是当前对象; - 使用特殊域变量(volatile)实现线程同步
- 使用重入锁(Lock)实现线程同步
- 使用局部变量(ThreadLocal)实现线程同步
- 使用阻塞队列(LinkedBlockingQueue)实现线程同步
- 使用原子变量(Atomic)实现线程同步
同步方法
同步方法即有 synchronized 关键字修饰的方法。
由于 java 的每个对象都有一个内置锁,当用此关键字修饰方法时,内置锁会保护整个方法。在调用该方法前,需要获得内置锁,否则就处于阻塞状态。
示例如下:
1 | public synchronized void buy() { |
同步代码块
同步代码块即有 synchronized 关键字修饰的语句块。
被该关键字修饰的语句块会自动被加上内置锁,从而实现同步
代码示例:
1 | public void buy() { |
注:同步是一种高开销的操作,因此应该尽量减少同步的内容。通常没有必要同步整个方法,使用 synchronized 代码块同步关键代码即可。
synchronized
关键字 synchronized 的作用是实现线程间的同步。它的工作是对同步的代码加锁,使得每一次,只能有一个线程进入同步块,从而保证线程间的安全性。
- synchronized 修饰方法时,只要一个线程访问了此对象其中的一个 synchronized 方法,其它线程不能同时访问这个对象中任何一个 synchronized 方法。但是,不同的对象实例的 synchronized 方法是不相干扰的。也就是说,其它线程照样可以同时访问相同类的另一个对象实例中的 synchronized 方法。锁的是当前实例对象。
- synchronized 修饰类的静态方法时,对此类的所有实例对象起作用。锁的是当前类的所有实例对象
- 无论 synchronized 关键字加在方法上还是对象上,它取得的锁都是对象,而不是把一段代码或函数当作锁――否则同步方法很可能还会被其他线程的对象访问。
- synchronized 关键字是不能继承的,也就是说,基类的方法 synchronized f(){} 在继承类中并不自动是 synchronized f(){},而是变成了 f(){}。继承类需要你显式的指定它的某个方法为 synchronized 方法
使用特殊域变量(volatile)实现线程同步
- volatile 关键字为域变量的访问提供了一种免锁机制,
- 使用 volatile 修饰域相当于告诉虚拟机该域可能会被其他线程更新,
- 因此每次使用该域就要重新计算,而不是使用寄存器中的值
- volatile 不会提供任何原子操作,它也不能用来修饰 final 类型的变量
例如:
在上面的例子当中,在 ticketNums 前面加上 volatile 修饰
1 | private volatile int ticketNums = 100; |
你会发现运行结果还是不能避免重复购票的情况。这是因为ticketNums--
看起来是一个原子操作,但其实是由读取-修改-写入操作序列组成的组合操作,必须以原子方式执行,而 volatile 不能提供必须的原子特性。如果在读取后 ticketNums 的值又发生变化,volatile 是无法解决的。
volatile 条件
所以要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件需要满足以下两个条件:
- 对变量的写操作不依赖于当前值。
- 该变量没有包含在具有其他变量的不变式中。
实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。
volatile 适用场景
- 状态标志
- 一次性安全发布(one-time safe publication)
- 独立观察(independent observation)
- “volatile bean” 模式
- 开销较低的“读-写锁”策略
具体使用参考【Java 线程】volatile 的适用场景
使用重入锁实现线程同步
在 JavaSE5.0 中新增了一个java.util.concurrent
包来支持同步。
synchronized 和 Lock 区别
- Lock 是一个接口,而 synchronized 是 Java 中的关键字,synchronized 是内置的语言实现;
- synchronized 无法判断是否获取锁的状态,Lock 可以判断是否获取到锁;
- synchronized 会自动释放锁 (a 线程执行完同步代码会释放锁 ;b 线程执行过程中发生异常会释放锁),Lock 需在 finally 中手工释放锁(unlock () 方法释放锁),否则容易造成线程死锁;
- Lock 可以让等待锁的线程响应中断,而 synchronized 却不行,使用 synchronized 时,等待的线程会一直等待下去,不能够响应中断;
- Lock 锁适合大量同步的代码的同步问题,synchronized 锁适合代码少量的同步问题。
- Lock 可以提高多个线程读的效率
- synchronized 的锁可重入、不可中断、非公平,而 Lock 锁可重入(两者皆可)、可中断、可公平;
ReentrantLock 类是可重入、互斥、实现了 Lock 接口的锁,
它与 synchronized 具有相同的基本行为和语义,并且扩展了其能力
ReenreantLock 类的常用方法有:
- ReentrantLock() : 创建一个 ReentrantLock 实例
- lock() : 获得锁
- unlock() : 释放锁
注:ReentrantLock()还有一个可以创建公平锁的构造方法,但是会大幅度降低程序运行效率,不推荐使用
代码示例:
1 | class UnsafeBuyTicket implements Runnable { |
使用局部变量实现线程同步
ThreadLocal 是一个本地线程副本变量工具类。主要用于将私有线程和该线程存放的副本对象做一个映射,各个线程之间的变量互不干扰,在高并发场景下,可以实现无状态的调用,特别适用于各个线程依赖不同的变量值完成操作的场景。
ThreadLocal 的作用主要是做数据隔离,填充的数据只属于当前线程,变量的数据对别的线程而言是相对隔离的,在多线程环境下,防止自己的变量被其它线程篡改。
核心机制
- 每个 Thread 线程内部都有一个 Map。
- Map 里面存储线程本地对象(key)和线程的变量副本(value)
- 但是,Thread 内部的 Map 是由 ThreadLocal 维护的,由 ThreadLocal 负责向 map 获取和设置线程的变量值。
所以对于不同的线程,每次获取副本值时,别的线程并不能获取到当前线程的副本值,形成了副本的隔离,互不干扰。
ThreadLocal 在 Spring 中发挥着巨大的作用,在管理 Request 作用域中的 Bean、事务管理、任务调度、AOP 等模块都出现了它的身影。
Spring 中绝大部分 Bean 都可以声明成 Singleton 作用域,采用 ThreadLocal 进行封装,因此有状态的 Bean 就能够以 singleton 的方式在多线程中正常工作了。
ThreadLocal 类的常用方法
- ThreadLocal() : 创建一个线程本地变量
- get() : 获取当前线程的副本变量值。
- initialValue() : 返回此线程局部变量的当前线程的”初始值”
- set(T value) : 设置当前线程的副本变量值为 value。
- remove():移除当前线程的副本变量值。
1 | class UnsafeBuyTicket implements Runnable { |
如上,修改代码后可以看到三个线程的变量值互不影响。
ThreadLocal 适用场景
例:Hibernate 的 session 获取场景。
每个线程访问数据库都应当是一个独立的 Session 会话,如果多个线程共享同一个 Session 会话,有可能其他线程关闭连接了,当前线程再执行提交时就会出现会话已关闭的异常,导致系统异常。此方式能避免线程争抢 Session,提高并发下的安全性。
使用 ThreadLocal 的典型场景正如上面的数据库连接管理,线程会话管理等场景,只适用于独立变量副本的情况,如果变量为全局共享的,则不适用在高并发下使用。
又或者:时间格式:SimpleDataFormat
使用阻塞队列实现线程同步
前面 5 种同步方式都是在底层实现的线程同步,但是我们在实际开发当中,应当尽量远离底层结构。
使用 javaSE5.0 版本中新增的java.util.concurrent
包将有助于简化开发。
本小节主要是使用LinkedBlockingQueue<E>
来实现线程的同步LinkedBlockingQueue<E>
是一个基于已连接节点的,范围任意的 blocking queue。
LinkedBlockingQueue 类常用方法
- LinkedBlockingQueue() : 创建一个容量为 Integer.MAX_VALUE 的- LinkedBlockingQueue
- put(E e) : 在队尾添加一个元素,如果队列满则阻塞
- size() : 返回队列中的元素个数
- take() : 移除并返回队头元素,如果队列空则阻塞
使用阻塞队列实现生产和消费,代码如下:
1 |
|
注:BlockingQueue<E>
定义了阻塞队列的常用方法,尤其是三种添加元素的方法需要多加注意,当队列满时:
- add()方法会抛出异常
- offer()方法返回 false
- put()方法会阻塞
使用原子变量实现线程同步
需要使用线程同步的根本原因在于对普通变量的操作不是原子的。
在 java 的 util.concurrent.atomic 包中提供了创建了原子类型变量的工具类,使用该类可以简化线程同步。
其中AtomicInteger
包可以用原子方式更新 int 的值,可用在应用程序中(如以原子方式增加的计数器),
但不能用于替换 Integer;可扩展 Number,允许那些处理机遇数字类的工具和实用工具进行统一访问。
AtomicInteger 类常用方法:
- AtomicInteger(int initialValue) : 创建具有给定初始值的新的 AtomicInteger
- addAddGet(int dalta) : 以原子方式将给定值与当前值相加
- get() : 获取当前值
代码示例:
1 | class UnsafeBuyTicket implements Runnable { |
参考
Java反射