Java 里面进行多线程通信的主要方式就是共享内存的方式,共享内存主要的关注点有两个:可见性和有序性原子性。
Java 实现线程同步有如下几种方式
- 使用 synchronized 或 lock 锁
- 使用volatile 修饰变量
- 使用ThreadLocal
- 使用J.U.C 的类库,如原子操作类、Semaphore信号量、并发集合类等
以下便一一讲解以下
一、Synchronized
synchronized 又称隐式锁(不需要加锁、解锁的操作)
synchronized 有两种使用方式:修饰方法、修饰代码块,特点如下:
- synchronized 修饰方法是类锁、修饰代码块是对象锁,
- 如果同步方法或代码块被static修饰时也为类锁,因为同一个对象的多个实例,在进入静态的同步方法时,一次只能有一个类实例进入,所以同步方法体效率和性能没有同步块高
- synchronized 作用于一个对象实例时,锁住的是所有以该对象为锁的代码块。
// 加锁方法
public synchronized void synMethod){
// 方法体
}
// 加锁代码块
public void synMethod(){
synchronized(Object){ // Object 指锁对象
// 方法体
}
}
同步方法和同步代码块的区别
- 修饰方法,在调用该方法前,需要获得当前对象锁,即类锁,等同于synchronized(this), 是对整个类对象加锁
- 修饰代码块,在调用该代码块时,可以指定任一对象作为锁
代码演示:
class SynObj{
public synchronized void showA(){
System.out.println("showA");
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public void showB(){
synchronized (this) {
System.out.println("showB");
}
}
public void showC(){
String s="1";
synchronized (s) {
System.out.println("showC");
}
}
}
public class Test {
public static void main(String[] args) {
final SynObj sy=new SynObj();
new Thread(new Runnable() {
public void run() {
sy.showA();
}
}).start();
new Thread(new Runnable() {
public void run() {
sy.showB();
}
}).start();
new Thread(new Runnable() {
public void run() {
sy.showC();
}
}).start();
}
}
执行以上代码,输出如下
showA
showC
showB
showB 是延迟3秒才打印的,这是因为同步方法showA 使用的是类锁。
二、显式锁Lock
Lock 是一个接口提供了无条件、可轮询、定时、可中断的锁获取操作,加锁和解锁都是显式的
包路径是:java.util.concurrent.locks.Lock
主要核心方法:lock()、tryLock()、unlock() 、lockInterruptibly ()
其实现类有:ReenTrantLock 、ReentrantReadWriteLock.ReadLock、ReentrantReadWriteLock.WritedLock
1. Lock核心方法
lock()
是一个阻塞方法,调用后一直阻塞直到获得锁。阻塞过程中不会接受中断信号,忽视interrupt(), 拿不到锁就 一直阻塞。即:拿不到lock就不罢休,不然线程就一直block。 比较无赖的做法。
tryLock
马上返回,拿到lock就返回true,不然返回false。 比较潇洒的做法。带时间限制的tryLock(),拿不到lock,就等一段时间,超时返回false。比较聪明的做法。
lockInterruptibly
调用后如果没有获取到锁会一直阻塞,阻塞过程中会接受中断信号,即 线程在请求lock并被阻塞时,如果被interrupt,则“此线程会被唤醒并被要求处理InterruptedException”。
2. Lock 主要方法
- void lock(): 执行此方法时, 如果锁处于空闲状态, 当前线程将获取到锁. 相反, 如果锁已经被其他线程持有, 将禁用当前线程, 直到当前线程获取到锁.
- boolean tryLock():如果锁可用, 则获取锁, 并立即返回 true, 否则返回 false. 该方法和lock()的区别在于, tryLock()只是"试图"获取锁, 如果锁不可用, 不会导致当前线程被禁用,当前线程仍然继续往下执行代码. 而 lock()方法则是一定要获取到锁, 如果锁不可用, 就一直等待, 在未获得锁之前,当前线程并不继续向下执行.
- void unlock():执行此方法时, 当前线程将释放持有的锁. 锁只能由持有者释放, 如果线程并不持有锁, 却执行该方法, 可能导致异常的发生.
- Condition newCondition():条件对象,获取等待通知组件。该组件和当前的锁绑定,当前线程只有获取了锁,才能调用该组件的 await()方法,而调用后,当前线程将缩放锁。
- getHoldCount() :查询当前线程保持此锁的次数,也就是执行此线程执行 lock 方法的次数。
- getQueueLength():返回正等待获取此锁的线程估计数,比如启动 10 个线程,1 个线程获得锁,此时返回的是 9
- getWaitQueueLength:(Condition condition)返回等待与此锁相关的给定条件的线程估计数。比如 10 个线程,用同一个 condition 对象,并且此时这 10 个线程都执行了condition 对象的 await 方法,那么此时执行此方法返回 10
- hasWaiters(Condition condition):查询是否有线程等待与此锁有关的给定条件(condition),对于指定 contidion 对象,有多少线程执行了 condition.await 方法
- hasQueuedThread(Thread thread):查询给定线程是否等待获取此锁
- hasQueuedThreads():是否有线程等待此锁
- isFair():该锁是否公平锁
- isHeldByCurrentThread(): 当前线程是否保持锁锁定,线程的执行 lock 方法的前后分别是 false 和 true
- isLock():此锁是否有任意线程占用
- lockInterruptibly():如果当前线程未被中断,获取锁
- tryLock():尝试获得锁,仅在调用时锁未被线程占用,获得锁
- tryLock(long timeout TimeUnit unit):如果锁在给定等待时间内没有被另一个线程保持,则获取该锁。
3. ReentrantLock
ReentrantLock是一个可重入的互斥锁,又被称为“独占锁”。ReentrantLock 类实现了 Lock ,除了能完成 synchronized 所能完成的所有工作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。
换句话说,当许多线程都想访问共享资源时,JVM 可以花更少的时候来调度线程,把更多时间用在执行线程上。
可重入的意思是:ReentrantLock锁可以被单个线程多次获取。
ReentrantLock分为“公平锁”和“非公平锁”
- 公平锁的机制下,线程依次排队获取锁;
- 非公平锁的机制下,线程通过竞争获取锁。
ReentrantLock 实现
public class MyService {
private Lock lock = new ReentrantLock();
//Lock lock=new ReentrantLock(true);//公平锁
//Lock lock=new ReentrantLock(false);//非公平锁
private Condition condition=lock.newCondition();//创建 Condition
public void testMethod() {
try {
lock.lock();//lock 加锁
//1:wait 方法等待:
//System.out.println("开始 wait");
condition.await();
//通过创建 Condition 对象来使线程 wait,必须先执行 lock.lock 方法获得锁
//:2:signal 方法唤醒
condition.signal();//condition 对象的 signal 方法可以唤醒 wait 线程
for (int i = 0; i < 5; i++) {
System.out.println("ThreadName=" + Thread.currentThread().getName()+ (" " + (i + 1)));
}
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
lock.unlock();
}
}
}
4. ReentrantReadWriteLock
为了提高性能,Java 提供了读写锁ReentrantReadWriteLock,在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,在一定程度上提高了程序的执行效率。
读写锁具有公平选择性、可重入性,锁降级的特点
读写锁分为读锁和写锁,多个读锁不互斥,读锁与写锁互斥,这是由 jvm 自己控制的,你只要上好相应的锁即可。
读锁:如果你的代码只读数据,可以很多人同时读,但不能同时写,那就上读锁
写锁:如果你的代码修改数据,只能有一个人在写,且不能同时读取,那就上写锁。总之,读的时候上读锁,写的时候上写锁!
总之,读的时候上读锁,写的时候上写锁!
Java 中 读 写 锁 有 个 接 口 java.util.concurrent.locks.ReadWriteLock , 也 有 具 体 的 实 现ReentrantReadWriteLock。
5. synchronized 和 ReentrantLock 的区别
两者的共同点:
- 都是用来协调多线程对共享对象、变量的访问
- 都是可重入锁,同一线程可以多次获得同一个锁
- 都保证了可见性和互斥性
两者的不同点:
- ReentrantLock 是 JDK 实现的,synchronized 是 JVM 实现的
- ReentrantLock 是 Lock 接口的实现,而 synchronized 是 Java 中的关键字
- ReentrantLock 显示的获得、释放锁,synchronized 隐式获得释放锁
- ReentrantLock 可响应中断、可轮回,synchronized 是不可以响应中断的,为处理锁的不可用性提供了更高的灵活性
- ReentrantLock 可以实现公平锁
- 底层实现不一样, synchronized 是同步阻塞,使用的是悲观并发策略,lock 是同步非阻塞,采用的是乐观并发策略
- 通过 Lock 可以知道有没有成功获取锁,而 synchronized 却无法办到。
- synchronized 在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而 Lock 在发生异常时,如果没有主动通过 unLock()去释放锁,则很可能造成死锁现象,因此使用 Lock 时需要在 finally 块中释放锁。
- Lock 可以提高多个线程进行读操作的效率,既就是实现读写锁等。
- ReentrantLock 通过 Condition 可以绑定多个条件
Condition
- Condition是在java 1.5中才出现的,它用来替代传统的Object的wait()、notify();使用Condition的await()、signal()这种方式实现线程间协作更加安全和高效。因此通常来说比较推荐使用Condition。
- Condition可以实现多路通知功能,也就是在一个Lock对象里可以创建多个Condition(即对象监视器)实例,线程对象可以注册在指定的Condition中,从而可以有选择的进行线程通知,在调度线程上更加灵活
// 实例化一个ReentrantLock对象
private ReentrantLock lock = new ReentrantLock();
// 为线程A注册一个Condition
public Condition conditionA = lock.newCondition();
// 为线程B注册一个Condition
public Condition conditionB = lock.newCondition();
6. Condition 类和 Object 类锁方法区别
- Condition 类的 awiat 方法和 Object 类的 wait 方法等效
- Condition 类的 signal 方法和 Object 类的 notify 方法等效
- Condition 类的 signalAll 方法和 Object 类的 notifyAll 方法等效
- ReentrantLock 类可以唤醒指定条件的线程,而 object 的唤醒是随机的
7. tryLock 和 lock 和 lockInterruptibly 的区别
- tryLock 能获得锁就返回 true,不能就立即返回 false,tryLock(long timeout,TimeUnitunit),可以增加时间限制,如果超过该时间段还没获得锁,返回 false
- lock 能获得锁就返回 true,不能的话一直等待获得锁
- lock 和 lockInterruptibly,如果两个线程分别执行这两个方法,但此时中断这两个线程,lock 不会抛出异常,而 lockInterruptibly 会抛出异常。
三、volatile
volatile 变量具备两种特性:变量可见性、禁止重排序
即 volatile 变量,用来确保将变量的更新操作通知到其他线程。volatile 变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取 volatile 类型的变量时总会返回最新写入的值。比 sychronized 更轻量级的同步锁
并发编程中,我们通常会遇到以下三个问题:原子性问题,可见性问题,有序性问题。
java多线程中的原子性、可见性、有序性
- 原子性:是指线程的多个操作是一个整体,不能被分割,要么就不执行,要么就全部执行完,中间不能被打断。
- 可见性:是指线程之间的可见性,就是一个线程修改后的结果,其他的线程能够立马知道。
- 有序性:有序性就是代码执行顺序及是并发执行顺序。
1. 变量可见性
其一是保证该变量对所有线程可见,这里的可见性指的是当一个线程修改了变量的值,那么新的值对于其他线程是可以立即获取的。
2. 禁止重排序
volatile 禁止了指令重排。所以具备 有序性,但不能保证原子性,所以不适用于高并发环境做安全机制
3. 为什么不能保证原子性
举例说明:线程A首先得到了i的初始值100,但是还没来得及修改,就阻塞了,这时线程B开始了,它也得到了i的值,由于i的值未被修改,即使是被volatile修饰,主存的变量还没变化,那么线程B得到的值也是100,之后对其进行加1操作,得到101后,将新值写入到缓存中,再刷入主存中。根据可见性的原则,这个主存的值可以被其他线程可见。
问题来了,线程A已经读取到了i的值为100,也就是说读取的这个原子操作已经结束了,所以这个可见性来的有点晚,线程A阻塞结束后,继续将100这个值加1,得到101,再将值写到缓存,最后刷入主存,所以即便是volatile具有可见性,也不能保证对它修饰的变量具有原子性。
4. 适用场景
值得说明的是对 volatile 变量的单次读/写操作可以保证原子性的,如 long 和 double 类型变量,但是并不能保证 i++这种操作的原子性,因为本质上 i++是读、写两次操作。在某些场景下可以代替 Synchronized。但是,volatile 的不能完全取代 Synchronized 的位置,只有在一些特殊的场景下,才能适用 volatile。总的来说,必须同时满足下面两个条件才能保证在并发环境的线程安全:
- 对变量的写操作不依赖于当前值(比如 i++),或者说是单纯的变量赋值(booleanflag = true)。
- 该变量没有包含在具有其他变量的不变式中,也就是说,不同的 volatile 变量之间,不能互相依赖。只有在状态真正独立于程序内其他内容时才能使用 volatile。
5. volatile与synchronize的区别
- 都可以解决线程并发的问题,
- synchronized 修饰同步,用 volatile 修饰的成员变量
- volatile: 保证可见性,禁止指令重排序 ,不能保证原子性;synchronized:则可以保证变量的修改可见性和原子性
- volatile不会造成线程阻塞。synchronized可能会造成线程阻塞。
四、ThreadLocal
很多地方 ThreadLocal 叫做线程本地变量,也有些地方叫做线程本地存储;ThreadLocal 的作用是提供线程内的局部变量,这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或者组件之间一些公共变量的传递的复杂度。
使用场景
最常见的 ThreadLocal 使用场景为 用来解决 数据库连接、Session 管理等。
示例:
// JDBC 获取连接
private static ThreadLocal<Connection> threadLocal = new ThreadLocal<Connection>();
public static Connection getConnection() {
Connection conn = threadLocal.get();
if (conn == null) {
try {
conn = DriverManager.getConnection(
properties.getProperty("jdbc.url"),
properties.getProperty("jdbc.username"),
properties.getProperty("jdbc.password")
);
threadLocal.set(conn);
} catch (Exception e) {
throw new RuntimeException("建立数据库连接异常", e);
}
}
return conn;
}
五、J.U.C类库
1. 原子操作类型
在Java并发比编程中,要想保证一些操作不被其他线程干扰,就需要保证原子性,在 java.util.concurrent.atomic 的包下JDK中提供了16(jdk8增加了4个) 个原子操作类来帮助我们进行开发。可以在高并发环境下的高效程序处理,其基本的特性就是在多线程环境下,当有多个线程同时执行这些类的实例包含的方法时,由于一般 CPU 切换时间比 CPU 指令集操作更加长, 所以 J.U.C 在性能上有了很大的提升。
原子类如下:
- 基本类型:AtomicInteger、AtomicLong、AtomicBoolean
- 引用类型:AtomicReference、AtomicStampedeReference、AtomicMarkableReference
- 数组类型:AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray
- 属性原子修改器(Updater):AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater
- JDK8新增:DoubleAccumulator、LongAccumulator、DoubleAdder、LongAdder
我们知道,在多线程程序中,诸如++i 或 i++等运算不具有原子性,是不安全的线程操作之一。通常我们会使用 synchronized 将该操作变成一个原子操作,但 JVM 为此类操作特意提供了一些同步类,使得使用更方便,且使程序运行效率变得更高。通过相关资料显示,通常AtomicInteger的性能是 ReentantLock 的好几倍。
2. ConcurrentHashMap 并发
整个 ConcurrentHashMap 由一个个 Segment 组成,Segment 代表”部分“或”一段“的意思,所以很多地方都会将其描述为分段锁,线程安全(Segment 继承 ReentrantLock 加锁)
如果需要在 ConcurrentHashMap 中添加一个新的表项,并不是将整个 HashMap 加锁,而是首先根据 hashcode 得到该表项应该存放在哪个段中,然后对该段加锁,并完成 put 操作。在多线程环境中,如果多个线程同时进行 put操作,只要被加入的表项不存放在同一个段中,则线程间可以做到真正的并行
具体可以看一下 [Java-集合篇(5)集合之Map ](Java-集合篇(5)集合之Map 章节.md)章节
3. CAS(比较并交换-乐观锁机制-锁自旋)
CAS(Compare And Swap/Set)比较并交换,CAS 算法的过程是这样:它包含 3 个参数CAS(V,E,N)。V 表示要更新的变量(内存值),E 表示预期值(旧的),N 表示新值。当且仅当 V 值等于 E 值时,才会将 V 的值设为 N,如果 V 值和 E 值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。最后,CAS 返回当前 V 的真实值。
三种CAS方法
Atomic包里的类基本都是使用Unsafe下的CAS方法实现,Unsafe只提供了三种CAS方法: compareAndSwapObject、compareAndSwapInt 和 compateAndSwapLong,其他类型都是转成这三种类型再使用对应的方法去原子更新的。
4. AQS(抽象的队列同步器)
AbstractQueuedSynchronizer 类如其名,抽象的队列式的同步器,AQS 定义了一套多线程访问共享资源的同步器框架,许多同步类实现都依赖于它
范例演示:
public class AtomicIntegerTest {
private static final int THREADS_CONUT = 20;
public static AtomicInteger count = new AtomicInteger(0);
public static void increase() {
count.incrementAndGet();
}
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_CONUT];
for (int i = 0; i < THREADS_CONUT; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 1000; i++) {
increase();
}
}
});
threads[i].start();
}
while (Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(count);
}
}
5. ABA问题
CAS 会导致“ABA 问题”。CAS 算法实现一个重要前提需要取出内存中某时刻的数据,而在下时刻比较并替换,那么在这个时间差类会导致数据的变化。
比如说一个线程 one 从内存位置 V 中取出 A,这时候另一个线程 two 也从内存中取出 A,并且two 进行了一些操作变成了 B,然后 two 又将 V 位置的数据变成 A,这时候线程 one 进行 CAS 操作发现内存中仍然是 A,然后 one 操作成功。尽管线程 one 的 CAS 操作成功,但是不代表这个过程就是没有问题的。
部分乐观锁的实现是通过版本号(version)的方式来解决 ABA 问题
六、Semaphore 信号量
信号量Semaphore是java.util.concurrent包下一个常用的同步工具类;Semaphore 基于计数的信号量它可以设定一个阈值,基于此AQS的共享模式,可以控制同时访问的线程个数,通过acquire() 获取一个许可,如果没有(超过阈值),线程申请许可信号将会被阻塞,而 release() 释放一个许可或者线程被中断
使用场景:
我们经常用信号量来管理可重复使用的资源,比如数据库连接、线程等,因为这些资源都有着可预估的上限
那么为什么Semaphore没有单独同重设信号量数量的方法呢?直接把AQS的setState方法暴露出来不就行的吗?
因为setState操作如果发生在在某些使用该Semaphore的线程还没有走完整个信号量的获取和释放的流程时,将会直接导致state值的不准确。
范例:
操场上有5个跑道,一个跑道一次只能有一个学生在上面跑步,一旦所有跑道在使用,那么后面的学生就需要等待,直到有一个学生不跑了。
//操场
public class Playground {
//跑道
static class Track {
private int num;
public Track(int num) {
this.num = num;
}
@Override
public String toString() {
return "Track{num=" + num +'}';
}
}
private Track[] tracks = {new Track(1), new Track(2), new Track(3), new Track(4), new Track(5)};
private volatile boolean[] used = new boolean[5];
private Semaphore semaphore = new Semaphore(5, true);
//获取一个跑道
public Track getTrack() throws InterruptedException {
semaphore.acquire(1);
return getNextAvailableTrack();
}
//遍历,找到一个没人用的跑道
private Track getNextAvailableTrack() {
for (int i = 0; i < used.length; i++) {
if (!used[i]) {
used[i] = true;
return tracks[i];
}
}
return null;
}
//释放跑道
public void releaseTrack(Track track) {
if (makeAsUsed(track)){
semaphore.release(1);
}
}
//确认跑道使用情况
private boolean makeAsUsed(Track track) {
for (int i = 0; i < used.length; i++) {
if (tracks[i] == track) {
if (used[i]) {
used[i] = false;
return true;
} else {
return false;
}
}
}
return false;
}
}
测试类
public class SemaphoreDemo {
static class Student implements Runnable {
private int num;
private Playground playground;
public Student(int num, Playground playground) {
this.num = num;
this.playground = playground;
}
@Override
public void run() {
try {
//获取跑道
Playground.Track track = playground.getTrack();
if (track != null) {
System.out.println("学生" + num + "在" + track.toString() + "上跑步");
TimeUnit.SECONDS.sleep(2);
System.out.println("学生" + num + "释放" + track.toString());
//释放跑道
playground.releaseTrack(track);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
Executor executor = Executors.newCachedThreadPool();
Playground playground = new Playground();
for (int i = 0; i < 100; i++) {
executor.execute(new Student(i+1,playground));
}
}
}
七、死锁
定义:两个或者多个线程互相持有对方所需要的资源,导致这些线程处于无限期等待状态,无法继续执行
例如:线程1锁住了A资源并等待读取B资源,而线程2锁住了B资源并等待A资源,这样两个线程就发生了死锁现象。
死锁发生的条件:
- 互斥条件:一个资源每次只能被一个进程使用。
- 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持占有。
- 不剥夺条件:指进程已获得的资源,在未使用完之前,不能被剥夺,只能在使用完时由自己释放。
- 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。
如何避免线程死锁:只要破坏产生死锁的四个条件中的其中一个就可以了。
死锁范例
private static String A = "A";
private static String B = "B";
public static void main(String[] argc) {
new Thread(()-> {
synchronized (A) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (B) {
}
}
}).start();
new Thread(()->{
synchronized (B) {
synchronized (A) {
}
}
}).start();;
}