Java 多线程安全的使用小结
在使用多线程进行编程的过程中,难免遇到共享资源读写问题,这是为了线程安全,一种思路就是使用 锁
来控制并发读写问题。
在通过锁来实现并发安全中,常用的有以下几种:
- synchronized,对象锁
- ReentrantLock,重入锁
- ReentrantReadWriteLock,读写锁
今天从使用的角度来看看这几种锁是如何使用的。
1.synchronized
在学习这把内置重度锁之前,我们先看看在多线程并发写的安全示例:
demo
package org.example; public class MultiThreadWithNoLock { private static int count = 0; public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { for (int i = 0; i < 100000; i++) { count++; } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 100000; i++) { count--; } }); t1.start(); t2.start(); // 等待t1,t2执行结束 t1.join(); t2.join(); System.out.println(count); // -35622 } }
从结果来看,进行多次的静态属性 count
的写入之后,大概率得到的结果应该都不是0,了解 count++
或者 count--
都知道,这种操作并不是原子操作,其实可以分为 load -> operation -> store
三步,所以在多线程并发操作中,count 最后的值,并不是0。
如果我们使用重度锁-synchronized
,看看示例:
package org.example; public class MultiThreadWithLock { private static int count = 0; // lock private static Object lock = new Object(); public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { for (int i = 0; i < 100000; i++) { synchronized (lock) { count++; } } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 100000; i++) { synchronized (lock) { count--; } } }); t1.start(); t2.start(); // 等待t1,t2执行结束 t1.join(); t2.join(); System.out.println(count); // 0 } }
从结果来看,最后的count
的数值一定是0,这就是加了锁之后,可以保证同一时刻只有一个线程拥有锁,进而才能操作 count
资源。
synchronized 是Java中的关键字,通过利用锁的机制来实现互斥同步的。synchronized 可以保证同一时刻只有一个线程可以执行某个方法或者某个代码块。虽然说 synchronized 是重度锁,但 Java 在1.6版本以后,已对其做了大量优化,性能与 JUC 中的 Lock 和 ReadWriteLock 基本持平,如感兴趣可以进一步测试其性能。[1]
用法
从用法来看,synchronized 主要有以下三种用法:
- 1.synchronized 关键字修饰实例方法,
同步实例对象
,当前的实例对象即为锁 - 2.synchronized 关键字修饰类的静态方法,
同步静态方法
,当前类的 Class 对象即为锁 - 3.synchronized 关键字应用在
代码块
上,如 synchronized (Object) {...},Object对象即为锁,如果Object为类的Class对象,则对应为锁
下面为用法的简单示例。
同步实例方法
package org.example; public class SyncInstanceMethod { private int count; public synchronized void add() { count++; } }
同步静态方法
静态方法的同步是指,用 synchronized
修饰的静态方法,与使用所在类的 Class
对象实现的同步代码块,效果类似。因为在 JVM 中一个类只能对应一个类的 Class 对象,所以同时只允许一个线程执行同一个类中的静态同步方法。
对于同一个类中的多个静态同步方法,持有锁的线程可以执行每个类中的静态同步方法而无需等待。不管类中的哪个静态同步方法被调用,一个类只能由一个线程同时执行。[1]
package org.example; public class SyncClassStaticMethod { private static int count; public static synchronized void add() { count++; } }
同步代码块
package org.example; public class SyncCodeBlock { private int count; private Object lock = new Object(); public void add() { synchronized (lock) { count++; } System.out.println("Add count!"); } }
在使用 synchronized 同步实例方法
过程中要注意,如果同步中有其他实例方法,要注意我们使用的锁是只保证锁范围内的资源受到保护,所以这时应该主要有其他类的实例时,synchronized(xxxClass.class) {...}
,保证xxxClass.class 是全局唯一的。
线程挂起与唤醒-wait()/notify()/notifyAll()
在上文中,我们学习了 synchronized 可以给对象加锁,进而同一时刻已有一个线程获取锁,才能操作临界区。在涉及多线程的同步中,如何主动挂起线程、唤醒线程呢。答案就是 wait()和notify()、notifyAll()
。
方法解释
wait()
, 调用wait(),会自动释放当前线程占有的对象锁,并请求 OS 挂起当前线程,该线程就从Running -> Waitting
状态,等待notify/notifyAll
唤醒。注意,如果没有释放锁,其他线程是无法进入对象的同步方法或者同步的代码块,也就无法执行唤醒挂起的线程,从而造成死锁
。notify()
,唤醒一个在 waitting 状态的线程,并使它获取到锁,具体唤醒哪个线程,由 JVM 控制。notifyAll()
,唤醒所有的在 Waitting 状态的线程,然后这些线程去竞争获取锁。
需要注意的是,任何 Object 对象都有上面的三种方法,但是这里的线程挂起和唤醒不能单独使用,必须结合 synchronized (Object) {},在代码块中使用,否则会报 IllegalMonitorStateException 异常。
涉及具体原因,请参考[1]中:
为什么 `wait`、`notify`、`notifyAll` 不定义在 `Thread` 类中?为什么 `wait`、`notify`、`notifyAll` 要配合 `synchronized` 使用? 理解为什么这么设计,需要了解几个基本知识点: - 每一个 Java 对象都有一个与之对应的监视器(monitor) - 每一个监视器里面都有一个 对象锁 、一个 等待队列、一个 同步队列 了解了以上概念,我们回过头来理解前面两个问题。 为什么这几个方法不定义在 Thread 中? - 由于每个对象都拥有对象锁,让当前线程等待某个对象锁,自然应该基于这个对象(Object)来操作,而非使用当前线程(Thread)来操作。因为当前线程可能会等待多个线程释放锁,如果基于线程(Thread)来操作,就非常复杂了。 为什么 wait、notify、notifyAll 要配合 synchronized 使用? - 如果调用某个对象的 wait 方法,当前线程必须拥有这个对象的对象锁,因此调用 wait 方法必须在 synchronized 方法和 synchronized 代码块中。
下面是挂起与唤醒的demo
:
package org.example; import java.util.LinkedList; import java.util.NoSuchElementException; public class ProducerAndConsumerModel { public static void main(String[] args) throws InterruptedException { MessageQueue queue = new MessageQueue(2); // 多个生产者 for (int i = 0; i < 3; i++) { int id = i; new Thread(() -> { queue.put(new Message(id, "value:"+id)); }, "Producer" + i).start(); } Thread.sleep(100L); // 单个消费者 new Thread(() -> { while (true) { queue.take(); } }, "Consumer").start(); } } // msg queue owned by producer and consumer class MessageQueue { private LinkedList<Message> list = new LinkedList<>(); // capacity private int capacity; // wait timeout private static long timeout = 1000L; public MessageQueue(int capacity) { this.capacity = capacity; } // produce public void put(Message msg) { synchronized(list) { while (list.size() == capacity) { System.out.println("队列已满,生产者进入等待。"); try { list.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } list.addLast(msg); System.out.printf("生产者生产消息: %s\n", msg.toString()); // 生产者通知消费者 list.notifyAll(); } } // consume public Message take() throws NoSuchElementException { synchronized(list) { int retry = 3; while (list.isEmpty() && retry > 0) { System.out.println("队列为空,消费者进入等待。"); try { list.wait(timeout); retry--; } catch (InterruptedException e) { e.printStackTrace(); } } // retry = 0 or ele in queue Message msg = list.removeFirst(); // throws Exception System.out.printf("消费者消费消息:%s\n", msg.toString()); // 消费后通知生产者 list.notifyAll(); return msg; } } } class Message { private int id; private Object value; public Message(int id, Object value) { this.id = id; this.value = value; } @Override public String toString() { return "Message{id=" + id + ", value=" + value + "}"; } }
相关输出:
生产者生产消息: Message{id=0, value=value:0} 生产者生产消息: Message{id=2, value=value:2} 队列已满,生产者进入等待。 消费者消费消息:Message{id=0, value=value:0} 消费者消费消息:Message{id=2, value=value:2} 队列为空,消费者进入等待。 生产者生产消息: Message{id=1, value=value:1} 消费者消费消息:Message{id=1, value=value:1} 队列为空,消费者进入等待。 队列为空,消费者进入等待。 队列为空,消费者进入等待。 Exception in thread "Consumer" java.util.NoSuchElementException at java.base/java.util.LinkedList.removeFirst(LinkedList.java:281) at org.example.MessageQueue.take(ProducerAndConsumerModel.java:76) at org.example.ProducerAndConsumerModel.lambda$main$1(ProducerAndConsumerModel.java:23) at java.base/java.lang.Thread.run(Thread.java:1583)
在 demo 中,我们创建了一个容量为 2 的队列:
- 如果队列满了,生产者等待,被挂起,生产者需等消费者消费后,才有空间继续放入
msg
对象。 - 如果队列为空,消费者挂起等待,被挂起,消费者需等到生产者将
msg
对象放入队列,为了防止程序在 消费 时一直循环,设置个 retry 参数,到次数后退出,这里抛出 NoSuchElementException 可忽略。 - 如果队列还有空间,生产者放入数据,调用
list.notifyAll()
唤醒消费者可以消费。 - 如果队列有数据,消费者消费数据,调用
list.notifyAll()
唤醒生产者生产消息。
线程等待-join()
在结合 synchronized同步机制
下,通过调用 wait()和notify()及notifyAll()
实现不同线程间的消息同步,除此以外 join() 也可以实现子线程与主线程的同步。
先看个 demo
:
package org.example; public class JoinDemo { public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { try { for (int i = 0; i < 3; i++) { System.out.printf("第%d次打印\n", i+1); Thread.sleep(500L); } } catch (InterruptedException e) { e.printStackTrace(); } }); t1.start(); System.out.println("主线程结束!"); } }
运行结果:
主线程结束! 第1次打印 第2次打印 第3次打印
运行上面代码,可看到主线程和子线程各自运行,由于子线程有休眠,主线程得到执行很快就结束了,主线程和子线程各自独立运行。
在子线程加上 join() 调用后的demo
:
package org.example; public class JoinDemo { public static void main(String[] args) throws InterruptedException { Thread t1 = new Thread(() -> { try { for (int i = 0; i < 3; i++) { System.out.printf("第%d次打印\n", i+1); Thread.sleep(500L); } } catch (InterruptedException e) { e.printStackTrace(); } }); t1.start(); t1.join(); System.out.println("主线程结束!"); } }
运行结果:
第1次打印 第2次打印 第3次打印 主线程结束!
从结果中可看到,主线程等待子线程运行结束后,主线程才打印最后的内容,因为加了 join() 调用后,主线程会等子线程结束后,再执行主线程后面的代码。
在 Thread类
中,join()
有多个重载方法,即:
void join()
直接调用,阻塞主线程的后续执行,等待子线程执行结束void join(long, int)/join(long)
最多阻塞主线程给定时间,到时间后继续执行后续主线程代码boolean join(duration)
超时等待,到期后,返回子线程是否结束。结束则true
我们看到在该方法中会抛出 InterruptedException
,如果引用线程被中断,即抛出该异常。
synchronized可重入性
先看一个demo
:
package org.example; public class SyncReentrant { private int count; public synchronized void add() { count++; addAnother(); } public synchronized void addAnother() { count++; } public static void main(String[] args) throws InterruptedException { SyncReentrant syncReentrant = new SyncReentrant(); Thread t1 = new Thread(() -> { syncReentrant.add(); System.out.println(Thread.currentThread().getName() + " end"); }, "子线程1"); Thread t2 = new Thread(() -> { syncReentrant.add(); System.out.println(Thread.currentThread().getName() + " end"); }, "子线程2"); t1.start(); t2.start(); t1.join(); t2.join(); System.out.println("main Thread end"); } }
执行结果:
子线程2 end 子线程1 end main Thread end
我们在实例方法中内嵌调用了实例方法,在前面的学习中,我们知道 synchronized
加在实例方法中,就相当于获取了实例对象的锁,在 add()
中再次调用 addAnother()
也是成功的,说明 synchronized
是可重入的锁。
2.ReentrantLock
ReentrantLock
顾名思义,就是可重入的锁,这个锁是 JUC(java.util.concurrent,Java版本 >1.5)
中的工具类,我们可以通过这个工具类实现共享资源的的同步控制。
先看看这个锁的定义:
public class ReentrantLock implements Lock, java.io.Serializable {}
我们顺着源码走读,这个类实际实现了 Lock接口
,看看这个接口:
在 Lock接口
中:
void lock()
,获取锁,如果没有获得锁就一直等待获取锁boolean tryLock()
,尝试获取锁,通常与if分支
结合使用,实际获取到锁立即就返回 true,否则返回 falseBoolean tryLock(long time, TimeUnit unit)
,超时时间内尝试获取锁,如果在等待获取锁的过程中有其他线程打断,会抛异常void unlock()
,解锁,释放锁,一般为了保证锁不被一直占有,可加在try {} finally {lock.unlock()}
在了解了 ReentrantLock
实现的 Lock接口
后,基本就知道这个锁该怎么用了。但需要注意一点的是,这个重入锁实现了两种构造方法,一种是公平锁
(按阻塞等待的顺序获得锁),一种是非公平锁
(各个线程需要抢占锁),对应的源码实现:
// 默认构造方法,非公平锁 public ReentrantLock() { sync = new NonfairSync(); } // 通过fair参数设置true,构造公平锁,false构造非公平锁 public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); }
比较典型的应用示例:
加锁-解锁
package org.example; import java.util.concurrent.locks.ReentrantLock; public class ReentrantLockUsage { public static void main(String[] args) { ReentrantLock lock = new ReentrantLock(); // 加锁 lock.lock(); try { ... } finally { lock.unlock(); // 解锁 } } }
尝试加锁-解锁
package org.example; import java.util.concurrent.locks.ReentrantLock; public class ReentrantLockUsage { public static void main(String[] args) { ReentrantLock lock = new ReentrantLock(); // 尝试加锁 if (lock.tryLock() ) { try { ... } finally { lock.unlock(); // 解锁 } } } } // 带超时时间的尝试加锁 public class ReentrantLockUsage { public static void main(String[] args) throws InterruptedException { ReentrantLock lock = new ReentrantLock(); if (lock.tryLock(500L, TimeUnit.MILLISECONDS)) { try { ... } finally { lock.unlock(); } } } }
3.ReentrantReadWriteLock
了解过读写锁的应该知道,读写锁之间有一定的关系:
- 读锁与读锁之间不会互斥,可以同读
- 读锁写锁之间互斥,先读后写,或者先写后读,读写之间只有一个线程可以完成读或者写的操作
- 写锁与写锁之间互斥,先持有写锁的线程先操作,后序的写锁线程等待前面的写锁持有的线程操作完释放锁
读写锁的应用场景适合 读多写少
的场景。
参考 ReentrantLock
,这里先对源码进行简单的走读。
先看看 ReentrantReadWriteLock
的类声明:
public class ReentrantReadWriteLock implements ReadWriteLock, java.io.Serializable {...}
这里的 ReentrantReadWriteLock
实现了 ReadWriteLock接口
,那就看看这个接口:
public interface ReadWriteLock { /** * Returns the lock used for reading. * * @return the lock used for reading */ Lock readLock(); /** * Returns the lock used for writing. * * @return the lock used for writing */ Lock writeLock(); }
从接口的声明来看,一共有2个方法:
Lock readLock()
,返回一个锁,作为读锁Lock writeLock()
,返回一个锁,作为写锁
我们再回到 ReentrantReadWriteLock
类结构再看看:
类似于 ReentrantLock
内部也是有 Sync类
,用于同步机制,另外实现两个公平锁和非公平锁的内部类
,再就是ReadLock读锁类和WriteLock写锁类
,这些是内部类。
基于上面的这些内部类,在构造方法方面同样在构造时也提供了默认无参的构造方法
,有参的构造方
法,以及获取读锁的方法readLock()
和写锁方法writeLock()
。
进入源码再看看:
这里主要看声明的两个读写锁变量。
这里的构造方法中,无参构造中默认是非公平锁,有参的可以设置公平锁,同时新建读写锁对象。
这里主要注意两个读写锁的内部类和获取读写锁的方法。
接下来看看读写锁的简单应用:
package org.example; import java.util.concurrent.locks.ReentrantReadWriteLock; public class MyReadWriteLock { private int count; public static void main(String[] args) throws InterruptedException { MyReadWriteLock myReadWriteLock = new MyReadWriteLock(); ReentrantReadWriteLock lock = new ReentrantReadWriteLock(true); // 公平锁 ReentrantReadWriteLock.ReadLock readLock = lock.readLock(); ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock(); Thread t1 = new Thread(() -> { for (int i = 0; i < 3; i++) { readLock.lock(); System.out.println(Thread.currentThread().getName() + " count: " + myReadWriteLock.count); readLock.unlock(); } }, "Read-Thread-1"); Thread t2 = new Thread(() -> { try { for (int i = 0; i < 3; i++) { writeLock.lock(); myReadWriteLock.count++; Thread.sleep(500L); System.out.println(Thread.currentThread().getName() + " count: " + myReadWriteLock.count); writeLock.unlock(); } } catch (InterruptedException e) { e.printStackTrace(); } }, "Write-Thread-1"); Thread t3 = new Thread(() -> { for (int i = 0; i < 3; i++) { readLock.lock(); System.out.println(Thread.currentThread().getName() + " count: " + myReadWriteLock.count); readLock.unlock(); } }, "Read-Thread-2"); t1.start(); t2.start(); t3.start(); t1.join(); t2.join(); t3.join(); System.out.println("Main Thread end!"); /* Read-Thread-1 count: 0 Read-Thread-2 count: 0 Write-Thread-1 count: 1 Read-Thread-2 count: 1 Read-Thread-1 count: 1 Write-Thread-1 count: 2 Read-Thread-2 count: 2 Read-Thread-1 count: 2 Write-Thread-1 count: 3 Main Thread end! */ } }
上面的 demo
中,我们新建了三个子线程,其中两个读线程,一个写线程,读读可同时进行,读写互斥。
参考:
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 无需6万激活码!GitHub神秘组织3小时极速复刻Manus,手把手教你使用OpenManus搭建本
· C#/.NET/.NET Core优秀项目和框架2025年2月简报
· Manus爆火,是硬核还是营销?
· 终于写完轮子一部分:tcp代理 了,记录一下
· 【杭电多校比赛记录】2025“钉耙编程”中国大学生算法设计春季联赛(1)
2021-11-16 0654-最大二叉树
2021-11-16 0106-105-从中序与后序遍历序列中构造二叉树
2021-11-16 0226-翻转二叉树