深入理解高并发编程(二)
文章目录
- 深入理解高并发编程(二)
- synchronized
- 作用
- 使用方法
- 示例代码
- ReentrantLock
- 概述
- 示例代码
- ReentrantLock中的方法
- ReentrantReadWriteLock
- 介绍
- 特点
- 示例代码
- StampedLock
- 示例代码
- wait() 和 notify()
- 示例代码
- 并发编程——可见性问题
- 单核CPU
- 多核CPU存在可见性问题
- 总结
- 并发编程——原子性问题
- 解决方法
- 并发编程——有序性问题
- 指令重排序
- 编译器重排序
- 处理器重排序
- 解决方法(同步机制)
- 使用 volatile
- synchronized
- 使用显式的锁
- 使用并发工具类
- Java内存屏障
- 作用
synchronized
- 底层通过一个
monitor
监视器锁对象完成,被synchronized
修饰的代码段当它的monitor
被占用时候,会处于锁定状态加锁的过程就是获取monitor
锁权限的过程,0
代表monitor
没有枷锁1
代表monitor
加锁
作用
- 确保线程互斥的访问同步代码
- 保证共享变量的修改能即使可见
- 有效解决重排序问题
使用方法
- 修饰普通方法
- 修饰静态方法:对静态方法的同步本质上是对类的同步(静态方法本质上是属于类的方法,而不是对象上的方法)
- 修饰代码块:只对代码块中的方法进行同步
示例代码
- 经典售票案例
/**
* <p> 经典售票问题 </p>
* Runnable 方式创建线程
*
* @author: Lidong
* @time: 2020/8/6 20:55
**/
public class CreateThreadByRunnableTest {
private static final Logger logger = LoggerFactory.getLogger(CreateThreadByRunnableTest.class);
private static final int TICKET_NUM = 11;
/**
* <p> 经典售票问题 20个线程抢 11 张票 </p>
* 使用 Runnable 的方式创建代码可以达到相同代码公用共同的资源
* @throws InterruptedException InterruptedException
*/
@Test
public void test() throws InterruptedException {
BuyTicketsRunnable buyTicketTask = new BuyTicketsRunnable(TICKET_NUM);
int threadNum = 20;
for (int i = 0; i < threadNum; i++) {
Thread thread = new Thread(buyTicketTask);
thread.start();
if (Thread.holdsLock(Thread.currentThread())) {
logger.info("当前线程持有对象监视器!");
}
}
Thread.sleep(10000);
}
private class BuyTicketsRunnable implements Runnable {
private final Logger logger = LoggerFactory.getLogger(BuyTicketsRunnable.class);
private int ticketNum;
public BuyTicketsRunnable(int aTicketNum) {
this.ticketNum = aTicketNum;
}
@Override
public void run() {
synchronized (this) {
if (ticketNum > 0) {
ticketNum--;
logger.info("Thread {} 买到一张票 还剩:{} 张票", Thread.currentThread().getId(), ticketNum);
} else {
logger.info("Thread {} 没有抢到票 还剩:{} 张票", Thread.currentThread().getId(), ticketNum);
}
}
}
}
}
ReentrantLock
概述
- 可重入性:
ReentrantLock
是可重入锁,意味着同一个线程可以多次获取同一把锁而不会导致死锁。而synchronized
也是可重入的,同一个线程可以多次获取同一把锁。 ReentrantLock
提供了两种获取锁的方式,分别是公平锁和非公平锁,可以根据需要选择。synchronized
是非公平锁,即先尝试获取锁的线程有更高的优先级。- 使用
ReentrantLock
时,需要手动释放锁,即在finally
块中调用unlock()
方法。而synchronized
在代码块执行完毕或者异常时会自动释放锁。 ReentrantLock
的粒度更细,可以通过使用多个锁来实现更细粒度的同步控制,而synchronized
只能使用一个锁。ReentrantLock
提供了一些synchronized
不具备的功能,比如可中断、可定时、可轮询等。
示例代码
/**
* 三个窗口总共买 100 张票
*
* @throws InterruptedException interrupted exception
*/
@Test
public void test2() throws InterruptedException {
TicketTask ticketTask = new TicketTask();
new Thread(ticketTask, "1号窗口").start();
new Thread(ticketTask, "2号窗口").start();
new Thread(ticketTask, "3号窗口").start();
Thread.sleep(10000);
}
private class TicketTask implements Runnable {
private final Logger logger = LoggerFactory.getLogger(TicketTask.class);
private int tickets = 100;
private final Lock lock = new ReentrantLock(false);
@Override
public void run() {
while (true) {
// 上Lock锁
lock.lock();
try {
if (tickets > 0) {
--tickets;
logger.info("{} ======完成售票,余票为{}", Thread.currentThread().getName(), tickets);
} else {
logger.info("{} ======余票为{}", Thread.currentThread().getName(), tickets);
break;
}
} catch (Exception e) {
logger.error(e.getMessage(), e);
} finally {
// 释放 Lock 锁避免发生死锁
lock.unlock();
}
}
}
}
ReentrantLock中的方法
lock()
:获取锁。如果锁可用,则当前线程会立即获取锁并继续执行,否则当前线程会被阻塞直到获取到锁。lockInterruptibly()
:获取锁,但允许响应中断。如果锁可用,则当前线程会立即获取锁并继续执行,否则当前线程会被阻塞,直到获取到锁或者被其他线程中断。tryLock()
:尝试获取锁。如果锁可用,则当前线程会立即获取锁并返回true
,否则立即返回false
,不会阻塞。tryLock(long timeout, TimeUnit unit)
:在指定的时间内尝试获取锁。如果锁可用,则当前线程会立即获取锁并返回true
,否则会等待指定的时间,如果在等待时间内获取到锁则返回true
,否则返回false
。unlock()
:释放锁。当前持有锁的线程可以调用此方法来释放锁。isHeldByCurrentThread()
:判断当前线程是否持有该锁。如果当前线程是获取锁的线程,则返回true
,否则返回false
。
ReentrantReadWriteLock
介绍
ReentrantReadWriteLock
是Java
中提供的一种同步机制,用于管理共享资源的并发读写访问。- 它允许多个线程同时读取共享资源,但只允许一个线程写入资源。
特点
- 该锁由读锁和写锁两部分组成。
- 多个线程可以同时获取读锁,从而实现对共享资源的并发读取。
- 如果一个线程想要修改共享资源,它必须独占地获取写锁,从而阻止其他线程对资源的读写操作。
- 如果一个线程已经持有写锁,它可以重入地再次获取写锁,而不会被阻塞,因此称为 “可重入”。
示例代码
public class ReadWriteLockTest {
@Test
public void test() throws InterruptedException {
// 创建读写锁
ReadWriteLock lock = new ReentrantReadWriteLock();
// 读锁
Lock readLock = lock.readLock();
// 写锁
Lock writeLock = lock.writeLock();
Map<String, Object> map = new HashMap<>();
for (int i = 10; i > 0; i--) {
String key = String.valueOf(System.currentTimeMillis());
WriteTask writeTask = new WriteTask(writeLock, map, key);
ReadTask readTask = new ReadTask(readLock, map, key);
ThreadPoolUtils.executor(writeTask);
ThreadPoolUtils.executor(readTask);
}
Thread.sleep(10000);
}
/**
* 读任务
*/
private class ReadTask implements Runnable {
private final Logger logger = LoggerFactory.getLogger(ReadTask.class);
private Lock readLock;
private Map<String, Object> map;
private String key;
private ReadTask(Lock readLock, Map<String, Object> map, String key) {
this.readLock = readLock;
this.map = map;
this.key = key;
}
@Override
public void run() {
readLock.lock();
try {
if (Objects.nonNull(map)) {
Object object = map.get(key);
logger.info("ReadTask read value:{}", object);
}
} catch (Exception e) {
logger.error(e.getMessage(), e);
} finally {
// 释放锁
readLock.unlock();
}
}
}
/**
* 写任务
*/
private class WriteTask implements Runnable {
private final Logger logger = LoggerFactory.getLogger(WriteTask.class);
private Lock writeLock;
private Map<String, Object> map;
private String key;
private WriteTask(Lock writeLock, Map<String, Object> map, String key) {
this.writeLock = writeLock;
this.map = map;
this.key = key;
}
@Override
public void run() {
writeLock.lock();
try {
if (Objects.isNull(map)) {
map = new HashMap<>();
}
long l = System.currentTimeMillis();
map.put(key, l);
logger.info("WriteTask write value:{}", l);
} catch (Exception e) {
logger.error(e.getMessage(), e);
} finally {
// 释放锁
writeLock.unlock();
}
}
}
}
StampedLock
- 在使用
StampedLock
时,乐观读锁是一种特殊的读锁,它不会阻塞其他线程的写入操作。 - 如果在尝试获取乐观读锁后,没有其他线程获取写锁,那么读取操作可以直接进行,否则需要获取悲观读锁。
- 在读取操作中,首先尝试获取乐观读锁,如果乐观读锁有效,则直接读取共享资源;否则,需要获取悲观读锁,然后再次读取共享资源。
- 在写入操作中,直接获取写锁进行修改。 需要注意的是
StampedLock
并不支持重入锁,如果在同一线程中重复获取同一个锁,则会导致死锁。 StampedLock
还提供了其他方法,如tryReadLock()
tryWriteLock()
等,用于尝试非阻塞地获取读锁或写锁。
示例代码
public class StampedLockTest {
private static final Logger logger = LoggerFactory.getLogger(StampedLockTest.class);
@Test
public void test1() throws InterruptedException {
StampedLock lock = new StampedLock();
List<String> list = new ArrayList<>();
ReadTask readTask = new ReadTask(lock, list);
WriteTask writeTask = new WriteTask(lock, list);
for (int i = 0; i < 20; i++) {
new Thread(readTask).start();
new Thread(writeTask).start();
}
Thread.sleep(10000);
}
/**
* 读任务
*/
private class ReadTask implements Runnable {
private StampedLock lock;
List<String> list;
public ReadTask(StampedLock lock, List<String> list) {
this.lock = lock;
this.list = list;
}
@Override
public void run() {
// 尝试获取乐观读锁
long stamp = lock.tryOptimisticRead();
// 检查乐观读锁是否有效
if (!lock.validate(stamp)) {
// 获取悲观读锁
stamp = lock.readLock();
try {
logger.info("读取到值:{}", list.toString());
} finally {
// 释放读锁
lock.unlockRead(stamp);
}
}
}
}
private class WriteTask implements Runnable {
private StampedLock lock;
List<String> list;
public WriteTask(StampedLock lock, List<String> list) {
this.lock = lock;
this.list = list;
}
@Override
public void run() {
// 获取写锁
long stamp = lock.writeLock();
try {
// 修改共享资源
list.add(UUID.randomUUID().toString());
logger.info("写入值后 list 的大小:{}", list.size());
} finally {
// 释放写锁
lock.unlockWrite(stamp);
}
}
}
}
wait() 和 notify()
-
wait()
和notify()
方法用于线程之间的通信和同步。它们通常在多个线程需要协调它们的操作时使用。 -
wait()
是在Object
类中定义的方法,它允许一个线程释放它持有的锁,并等待直到另一个线程通知它恢复执行。 -
当一个线程调用
wait()
时,它进入等待状态,直到另一个线程在相同的对象上调用notify()
或notifyAll()
。
示例代码
public class WaitNotifyTest {
private static final Logger logger = LoggerFactory.getLogger(WaitNotifyTest.class);
@Test
public void test1() throws InterruptedException {
for (int i = 0; i < 10; i++) {
Message message = new Message();
int finalI = i;
ThreadPoolUtils.executor(new Thread(() -> {
logger.info(message.getMessage());
}));
ThreadPoolUtils.executor(new Thread(() -> {
message.setMessage("消息" + finalI);
}));
}
Thread.sleep(10000);
}
private class Message {
private String content;
private boolean isMessageReady;
public synchronized void setMessage(String content) {
while (isMessageReady) {
try {
// 等待,直到消息被消费
logger.info("消息没有被消费,等待被消费:{}", content);
wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
this.content = content;
isMessageReady = true;
// 唤醒等待的线程
notify();
}
public synchronized String getMessage() {
while (!isMessageReady) {
try {
// 等待,直到消息被设置
logger.info("消息为空等待有消息", content);
wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
isMessageReady = false;
// 唤醒等待的线程
notify();
return content;
}
}
}
并发编程——可见性问题
- 一个线程对共享变量的修改,另一个线程能够立刻看到
- 可见性问题的主要原因是线程之间的操作是在各自的工作内存中进行的,每个线程都有自己的缓存,当一个线程修改了共享变量的值时,可能会将修改后的值存储在自己的缓存中,并不会立即写回主内存。其他线程在读取该变量时,可能会读取到自己缓存中的旧值,而不是最新的值
单核CPU
-
在单核
CPU
上,由于只有一个核心在执行指令,不存在多个线程同时执行的情况,因此可见性问题在理论上是不存在的。每个线程的操作都是按照顺序执行的,不会出现多个线程同时对共享变量进行读写的情况。 -
在单核
CPU
上,如果多个线程同时访问共享变量,并且没有适当的同步机制来保证可见性,仍然可能出现可见性问题
多核CPU存在可见性问题
- 在多核
CPU
上,每个CPU
的内核都有自己的缓存。当多个不同的线程运行在不同的CPU
内核上时,这些线程操作的是不同的CPU
缓存。一个线程对其绑定的CPU
的缓存的写操作,对于另外一个线程来说,不一定是可见的,这就造成了线程的可见性问题。
Java
并发程序运行在多核CPU
上时,线程的私有内存,也就是工作内存就相当于多核CPU
中每个CPU
内核的缓存了。
总结
- 可见性是一个线程对共享变量的修改,另一个线程能够立刻看到,如果不能立刻看到,就可能会产生可见性问题。
- 可见性问题还是由
CPU
的缓存导致的,而缓存导致的可见性问题是导致诸多诡异的并发编程问题的幕后黑手之一。
并发编程——原子性问题
- 原子性是指一个或者多个操作在
CPU
中执行的过程不被中断的特性。原子性操作一旦开始运行,就会一直到运行结束为止,中间不会有中断的情况发生。
解决方法
Java
提供了一些机制来解决原子性问题,包括以下几种方式:
synchronized
关键字:使用synchronized
关键字可以将代码块或方法标记为同步代码,确保同一时间只有一个线程可以执行该代码块或方法。这样可以保证对共享变量的操作是原子的。volatile
关键字:使用volatile
关键字可以保证共享变量的可见性,即一个线程对共享变量的修改对其他线程是立即可见的。虽然volatile
不能解决原子性问题,但可以用来保证对共享变量的读写操作是原子的。- 原子类(
Atomic Classes
):Java
提供了一系列原子类,如AtomicInteger
、AtomicLong
等,它们提供了一些原子操作方法,可以保证对共享变量的操作是原子的。 - 锁(
Lock
):Java
中的锁机制可以用来保护临界区,确保同一时间只有一个线程可以进入临界区。通过使用锁,可以保证对共享变量的操作是原子的。 - 并发容器:
Java
提供了一些线程安全的容器类,如ConcurrentHashMap
、ConcurrentLinkedQueue
等,这些容器类内部使用了一些并发技术来保证对容器的操作是线程安全的,从而避免原子性问题。
需要注意的是,并发编程中的原子性问题不仅仅限于对共享变量的操作,还可能涉及到多个操作的组合,这时候就需要考虑使用更高级的并发编程技术,如原子操作的组合、事务等。
并发编程——有序性问题
- 当多个线程执行的顺序对于程序的正确性很重要时,如果线程的执行顺序不符合预期,就会导致错误。例如,如果一个线程在另一个线程之前执行了某个操作,而我们期望它在后面执行,就可能导致错误。
- 解决顺序性问题的方法包括使用
volatile
关键字、synchronized
关键字、显式的同步机制或使用并发工具类中提供的有序性保证。 CPU
为了对程序进行优化,会对程序的指令进行重排序,此时程序的执行顺序和代码的编写顺序不一定一致,这就可能会引起有序性问题。
指令重排序
- 指令重排序是指在执行程序时,为了提高性能,编译器和处理器可能会对指令进行重新排序,但是保证最终的执行结果与单线程下的执行结果一致。在多线程环境下,指令重排序可能会导致一些意想不到的结果,因为多线程的执行顺序是不确定的。
编译器重排序
- 编译器在生成字节码时会对指令进行优化和重排序,以提高程序的执行效率。这种重排序是在单线程环境下的,不会影响单线程程序的执行结果。
处理器重排序
现代处理器为了提高指令的执行效率,可能会对指令进行乱序执行或重排序。处理器重排序是在单线程环境下的,同样不会影响单线程程序的执行结果。
解决方法(同步机制)
使用 volatile
- 将共享变量声明为
volatile
可以确保对该变量的读写操作具有可见性。当一个线程修改了volatile
变量的值,该值会立即被写回主内存,并且其他线程可以立即看到最新的值,从而解决了可见性问题。
private static volatile ThreadPoolExecutor threadPool;
synchronized
- 使用
synchronized
关键字可以确保多个线程对共享变量的访问具有原子性和有序性。synchronized
关键字可以用来修饰方法或代码块,当一个线程获取了对象的锁时,其他线程必须等待该线程释放锁才能继续执行。这样可以保证同一时刻只有一个线程能够访问共享变量,从而解决了原子性和有序性问题。 - 懒汉模式的使用
public class LazySingleton {
private static final Logger logger = LoggerFactory.getLogger(LazySingleton.class);
/**
* 保证lazySingleton在线程中同步
*/
private static volatile LazySingleton lazySingleton;
/**
* 保证类不在别的地方被实例化
*/
private LazySingleton() {
}
/**
* synchronize保证线程安全
*/
public static synchronized LazySingleton getInstance() {
if (null == lazySingleton) {
logger.info("懒汉式单例创建对象!");
lazySingleton = new LazySingleton();
}
return lazySingleton;
}
}
使用显式的锁
Java
提供了显式的锁机制,如ReentrantLock
,可以使用lock()
和unlock()
方法来手动控制线程的加锁和解锁操作。显式的锁机制提供了更灵活的同步方式,并且可以使用条件变量来实现更复杂的线程间通信,从而解决有序性问题。ReentrantLock
锁的使用
/**
* Lock对象
*/
private static Lock LOCK = new ReentrantLock();
/**
* <p> simpleDateFormat1 定义为类变量 对 simpleDateFormat1 在使用的时候同步处理 </p>
* <p> 使用 Lock 锁 </p>
*/
@Test
public void test2() {
for (int i = 0; i < 1000; i++) {
new Thread(() -> {
LOCK.lock();
try {
String dateString = simpleDateFormat.format(new Date());
Date parse = simpleDateFormat.parse(dateString);
logger.info(simpleDateFormat.format(parse));
} catch (Exception e) {
logger.error(e.getMessage(), e);
} finally {
LOCK.unlock();
}
}).start();
}
}
使用并发工具类
Java
并发包中提供了一些并发工具类,如CountDownLatch
、CyclicBarrier
、Semaphore
等,它们可以用来控制多个线程的执行顺序和并发访问的数量。这些工具类提供了更高级的同步机制,可以解决复杂的有序性问题。
public class SemaphoreTest {
private static final Logger logger = LoggerFactory.getLogger(SemaphoreTest.class);
private static final Semaphore semaphore1 = new Semaphore(0);
private static final Semaphore semaphore2 = new Semaphore(0);
private class One extends Thread {
@Override
public void run() {
logger.info("=====》One线程执行完成...");
semaphore1.release();
}
}
private class Two extends Thread {
@Override
public void run() {
try {
semaphore1.acquire();
logger.info("=====》Two线程执行完成...");
semaphore2.release();
} catch (InterruptedException e) {
logger.error(e.getMessage(), e);
}
}
}
private class Three extends Thread {
@Override
public void run() {
try {
semaphore2.acquire();
logger.info("======》Three线程执行完成...");
semaphore2.release();
} catch (InterruptedException e) {
logger.error(e.getMessage(), e);
}
}
}
@Test
public void test1() throws InterruptedException {
Thread one = new One();
one.start();
Thread two = new Two();
two.start();
Thread three = new Three();
three.start();
Thread.sleep(5000);
logger.info("=====>三个子线程结束...");
}
}
Java内存屏障
Java
内存屏障(Memory Barriers
)是一种同步机制,用于控制编译器和处理器对内存操作的重排序和可见性。Load Barrier
(读屏障):确保在读操作之前,所有之前的读写操作都已经完成,防止读取到过期的数据。Store Barrier
(写屏障):确保在写操作之前,所有之前的读写操作都已经完成,防止写入的数据被重排序到后面的操作之前。
作用
- 保证可见性:内存屏障可以确保在屏障之前的操作对其他线程可见,防止数据的不一致性。
- 防止重排序:内存屏障可以禁止编译器和处理器对操作进行重排序,保证指令的执行顺序符合程序的逻辑。