多线程基础
进程
1、在计算机中,我们把一个任务称为一个进程,浏览器就是一个进程,视频播放器是另一个进程,类似的,音乐播放器和Word都是进程。
2、某些进程内部还需要同时执行多个子任务。例如,我们在使用Word时,Word可以让我们一边打字,一边进行拼写检查,同时还可以在后台进行打印,我们把子任务称为线程。
3、进程和线程的关系就是:一个进程可以包含一个或多个线程,但至少会有一个线程。
4、操作系统调度的最小任务单位其实不是进程,而是线程。常用的Windows、Linux等操作系统都采用抢占式多任务,如何调度线程完全由操作系统决定,程序自己不能决定什么时候执行,以及执行多长时间。
5、实现多任务的方法,有以下几种:
- 多进程模式(每个进程只有一个线程):
- 多线程模式(一个进程有多个线程):
- 多进程+多线程模式(复杂度最高):
进程vs线程
1、进程和线程是包含关系,但是多任务既可以由多进程实现,也可以由单进程内的多线程实现,还可以混合多进程+多线程。
2、进程和线程的特点
和多线程相比,多进程的缺点在于:
- 创建进程比创建线程开销大,尤其是在Windows系统上;
- 进程间通信比线程间通信要慢,因为线程间通信就是读写同一个变量,速度很快。
而多进程的优点在于:
多进程稳定性比多线程高,因为在多进程的情况下,一个进程崩溃不会影响其他进程,而在多线程的情况下,任何一个线程崩溃会直接导致整个进程崩溃。
多线程
1、Java语言内置了多线程支持:一个Java程序实际上是一个JVM进程,JVM进程用一个主线程来执行main()
方法,在main()
方法内部,我们又可以启动多个线程。此外,JVM还有负责垃圾回收的其他工作线程等。
2、和单线程相比,多线程编程的特点在于:多线程经常需要读写共享数据,并且需要同步。
3、Java多线程编程的特点又在于:
- 多线程模型是Java程序最基本的并发模型;
- 后续读写网络、数据库、Web开发等都依赖Java多线程模型。
创建新线程
1、要创建一个新线程非常容易,我们需要实例化一个Thread
实例,然后调用它的start()
方法。望新线程能执行指定的代码,有以下几种方法:
方法一:从Thread
派生一个自定义类,然后覆写run()
方法:
public class Main {
public static void main(String[] args) {
Thread t = new MyThread();
t.start(); // 启动新线程
}
}
class MyThread extends Thread {
@Override
public void run() {
System.out.println("start new thread!");
}
}
注意:start()
方法会在内部自动调用实例的run()
方法。
方法二:创建Thread
实例时,传入一个Runnable
实例:
public class Main {
public static void main(String[] args) {
Thread t = new Thread(new MyRunnable());
t.start(); // 启动新线程
}
}
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("start new thread!");
}
}
方法三:用Java8引入的lambda语法进一步简写
public class Main {
public static void main(String[] args) {
Thread t = new Thread(() -> {
System.out.println("start new thread!");
});
t.start(); // 启动新线程
}
}
2、要模拟并发执行的效果,我们可以在线程中调用Thread.sleep()
,强迫当前线程暂停一段时间,sleep()
传入的参数是毫秒。
public class Main {
public static void main(String[] args) {
System.out.println("main start...");
Thread t = new Thread() {
public void run() {
System.out.println("thread run...");
try {
Thread.sleep(10);
} catch (InterruptedException e) {}
System.out.println("thread end.");
}
};
t.start();
try {
Thread.sleep(20);
} catch (InterruptedException e) {}
System.out.println("main end...");
}
}
3、线程的优先级
可以对线程设定优先级,设定优先级的方法是:
Thread.setPriority(int n) // 1~10, 默认值5
优先级高的线程被操作系统调度的优先级较高,操作系统对高优先级线程可能调度更频繁,但我们决不能通过设置优先级来确保高优先级的线程一定会先执行。
线程的状态
1、在Java程序中,一个线程对象只能调用一次start()
方法启动新线程,并在新线程中执行run()
方法。一旦run()
方法执行完毕,线程就结束了。因此,Java线程的状态有以下几种:
- New:新创建的线程,尚未执行;
- Runnable:运行中的线程,正在执行
run()
方法的Java代码; - Blocked:运行中的线程,因为某些操作被阻塞而挂起;
- Waiting:运行中的线程,因为某些操作在等待中;
- Timed Waiting:运行中的线程,因为执行
sleep()
方法正在计时等待; - Terminated:线程已终止,因为
run()
方法执行完毕。
用一个状态转移图表示如下:
2、线程终止的原因有:
- 线程正常终止:
run()
方法执行到return
语句返回; - 线程意外终止:
run()
方法因为未捕获的异常导致线程终止; - 对某个线程的
Thread
实例调用stop()
方法强制终止(强烈不推荐使用)。
3、一个线程还可以等待另一个线程直到其运行结束。例如,main
线程在启动t
线程后,可以通过t.join()
等待t
线程结束后再继续运行:
public class ThreadJoin {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
System.out.println("hello");
});
System.out.println("start");
t.start();
//t.join()等待t线程结束后再继续运行
t.join();
System.out.println("end");
}
}
start
hello
end
如果t
线程已经结束,对实例t
调用join()
会立刻返回。此外,join(long)
的重载方法也可以指定一个等待时间,超过等待时间后就不再继续等待。
中断线程
1、如果线程需要执行一个长时间任务,就可能需要能中断线程。中断线程就是其他线程给该线程发一个信号,该线程收到信号后结束执行run()
方法,使得自身线程能立刻结束运行。
例如:假设从网络下载一个100M的文件,如果网速很慢,用户等得不耐烦,就可能在下载过程中点“取消”,这时,程序就需要中断下载线程的执行。
2、中断一个线程非常简单,只需要在其他线程中对目标线程调用**interrupt()
**方法,目标线程需要反复检测自身状态是否是interrupted状态,如果是,就立刻结束运行。
public class ThreadInterrupt {
public static void main(String[] args) throws InterruptedException {
Thread t = new MyThread1();
t.start();
Thread.sleep(1); //暂停一毫秒
t.interrupt(); //中断t线程
t.join(); //等待t线程结束
System.out.println("end");
}
}
class MyThread1 extends Thread {
@Override
public void run() {
int n = 0;
while (!isInterrupted()) {
n++;
System.out.println(n + " hello!");
}
}
}
如果线程处于等待状态,例如,t.join()
会让main
线程进入等待状态,此时,如果对main
线程调用interrupt()
,join()
方法会立刻抛出InterruptedException
,因此,目标线程只要捕获到join()
方法抛出的InterruptedException
,就说明有其他线程对其调用了interrupt()
方法,通常情况下该线程应该立刻结束运行。
public class InterruptedExceptionDemo {
public static void main(String[] args) throws InterruptedException {
Thread t = new MyThread2();
t.start();
Thread.sleep(1000);
t.interrupt(); // 中断t线程
t.join(); // 等待t线程结束
System.out.println("end");
}
}
class MyThread2 extends Thread {
public void run() {
Thread hello = new HelloThread();
hello.start(); // 启动hello线程
try {
hello.join(); // 等待hello线程结束
} catch (InterruptedException e) {
System.out.println("interrupted!");
}
hello.interrupt();
}
}
class HelloThread extends Thread {
public void run() {
int n = 0;
while (!isInterrupted()) {
n++;
System.out.println(n + " hello!");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
break;
}
}
}
}
main
线程通过调用t.interrupt()
从而通知t
线程中断,而此时t
线程正位于hello.join()
的等待中,此方法会立刻结束等待并抛出InterruptedException
。由于我们在t
线程中捕获了InterruptedException
,因此,就可以准备结束该线程。在t
线程结束前,对hello
线程也进行了interrupt()
调用通知其中断。如果去掉这一行代码,可以发现hello
线程仍然会继续运行,且JVM不会退出。
3、另一个常用的中断线程的方法是设置标志位。我们通常会用一个running
标志位来标识线程是否应该继续运行,在外部线程中,通过把HelloThread.running
置为false
,就可以让线程结束。
public class InterruptRunning {
public static void main(String[] args) throws InterruptedException {
HelloThread2 t = new HelloThread2();
t.start();
Thread.sleep(1);
t.running = false;// 标志位置为false
}
}
class HelloThread2 extends Thread {
//volatile是线程间共享的变量,确保每个线程都能读取到更新后的变量值
public volatile boolean running = true;
public void run() {
int n = 0;
while (running) {
n++;
System.out.println(n + "hello!");
}
System.out.println("end!");
}
}
为什么要对线程间共享的变量用关键字volatile
声明?这涉及到Java的内存模型。在Java虚拟机中,变量的值保存在主内存中,但是,当线程访问变量时,它会先获取一个副本,并保存在自己的工作内存中。如果线程修改了变量的值,虚拟机会在某个时刻把修改后的值回写到主内存,但是,这个时间是不确定的!这会导致如果一个线程更新了某个变量,另一个线程读取的值可能还是更新前的。
因此,volatile
关键字的目的是告诉虚拟机:
- 每次访问变量时,总是获取主内存的最新值;
- 每次修改变量后,立刻回写到主内存。
volatile
关键字解决的是可见性问题:当一个线程修改了某个共享变量的值,确保其他线程能够立刻看到修改后的值。
守护线程
1、守护线程的背景
- Java程序入口是由JVM启动
main
线程,main
线程又可以启动其他线程。当所有线程都运行结束时,JVM退出,进程结束。 - 如果有一个线程没有退出,JVM进程就不会退出。所以,必须保证所有线程都能及时结束。
- 但是有一种线程的目的就是无限循环,问题是,由谁负责结束这个线程?
2、守护线程是指为其他线程服务的线程。在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。因此,JVM退出时,不必关心守护线程是否已结束。
3、创建守护线程
方法和普通线程一样,只是在调用start()
方法前,调用setDaemon(true)
把该线程标记为守护线程:
Thread t = new MyThread();
t.setDaemon(true);
t.start();
在守护线程中,编写代码要注意:守护线程不能持有任何需要关闭的资源,例如打开文件等,因为虚拟机退出时,守护线程没有任何机会来关闭文件,这会导致数据丢失。
线程同步
1、当多个线程同时运行时,线程的调度由操作系统决定,程序本身无法决定。因此,任何一个线程都有可能在任何指令处被操作系统暂停,然后在某个时间段后继续执行。如果多个线程同时读写共享变量,会出现数据不一致的问题,**需要通过synchronized
同步。**同步的本质就是给指定对象加锁,加锁后才能继续执行后续代码。
2、如果多个线程同时读写共享变量,会出现数据不一致的问题。来看一个例子:
public class SynchronizedThreadDemo {
public static void main(String[] args) throws InterruptedException {
Thread add=new AddThread();
Thread dec=new DecThread();
add.start();
dec.start();
add.join();
dec.join();
System.out.println(Counter.count);
}
}
class Counter {
public static int count = 0;
}
class AddThread extends Thread {
public void run() {
for (int i = 0; i < 1000; i++) {
Counter.count += 1;
}
}
}
class DecThread extends Thread {
public void run() {
for (int i = 0; i < 1000; i++) {
Counter.count -= 1;
}
}
}
两个线程同时对一个int
变量进行操作,一个加1000次,一个减1000次,最后结果应该是0,但是,每次运行,结果实际上都是不一样的。
这是因为对变量进行读取和写入时,结果要正确,必须保证是原子操作。原子操作是指不能被中断的一个或一系列操作。
3、多线程模型下,要保证逻辑正确,对共享变量进行读写时,必须保证一组指令以原子方式执行:即某一个线程执行时,其他线程必须等待。
假设n
的值是100
,如果两个线程同时执行n = n + 1
,通过加锁和解锁的操作,就能保证3条指令总是在一个线程执行期间,不会有其他线程会进入此指令区间。即使在执行期线程被操作系统中断执行,其他线程也会因为无法获得锁导致无法进入此指令区间。只有执行线程将锁释放后,其他线程才有机会获得锁并执行。这种加锁和解锁之间的代码块我们称之为临界区(Critical Section),任何时候临界区最多只有一个线程能执行。
4、保证一段代码的原子性就是通过加锁和解锁实现的。Java程序使用synchronized
关键字对一个对象进行加锁,synchronized
保证了代码块在任意时刻最多只有一个线程能执行。
1)找出修改共享变量的线程代码块;
2)选择一个共享实例作为锁;
3)使用synchronized(lockObject) { ... }
。
synchronized(lock) {
n = n + 1;
}
5、使用synchronized
解决了多线程同步访问共享变量的正确性问题。但是,它的缺点是带来了性能下降。因为synchronized
代码块无法并发执行。此外,加锁和解锁需要消耗一定的时间,所以,synchronized
会降低程序的执行效率。
public class SynchronizedThreadDemo {
public static void main(String[] args) throws InterruptedException {
Thread add = new AddThread();
Thread dec = new DecThread();
add.start();
dec.start();
add.join();
dec.join();
System.out.println(Counter.count);
}
}
class Counter {
public static final Object lock = new Object();
public static int count = 0;
}
class AddThread extends Thread {
public void run() {
for (int i = 0; i < 1000; i++) {
synchronized (Counter.lock) { //获取锁
Counter.count += 1;
} //释放锁
}
}
}
class DecThread extends Thread {
public void run() {
for (int i = 0; i < 1000; i++) {
synchronized (Counter.lock) {
Counter.count -= 1;
}
}
}
}
6、JVM规范定义了几种原子操作:
- 基本类型赋值,例如:
int n = m
; - 引用类型赋值,例如:
List<String> list = anotherList
。
单条原子操作的语句不需要同步,如果是多行赋值语句,就必须保证是同步操作。
同步方法
1、Java程序依靠synchronized
对线程进行同步,使用synchronized
的时候,锁住的是哪个对象非常重要。让线程自己选择锁对象往往会使得代码逻辑混乱,也不利于封装。更好的方法是把synchronized
逻辑封装起来。
public class Counter1 {
private int count = 0;
public void add(int n) {
synchronized (this) {
count += n;
}
}
// public void dec(int n) {
// synchronized (this) {
// count -= n;
// }
// }
//用synchronized修饰的方法就是同步方法,它表示整个方法都必须用this实例加锁
public synchronized void dec(int n) {
count -= n;
}
public int get() {
return count;
}
}
2、如果一个类被设计为允许多线程正确访问,就说这个类就是“线程安全”的(thread-safe)。Java标准库的java.lang.StringBuffer
也是线程安全的。还有一些不变类,例如String
,Integer
,LocalDate
,它们的所有成员变量都是final
,多线程同时访问时只能读不能写,这些不变类也是线程安全的。最后,类似Math
这些只提供静态方法,没有成员变量的类,也是线程安全的。
除了上述几种少数情况,大部分类,例如ArrayList
,都是非线程安全的类,我们不能在多线程中修改它们。但是,如果所有线程都只读取,不写入,那么ArrayList
是可以安全地在线程间共享的。
没有特殊说明时,一个类默认是非线程安全的。
3、对于static
方法,是没有this
实例的,因为static
方法是针对类而不是实例。但是我们注意到任何一个类都有一个由JVM自动创建的Class
实例,因此,对static
方法添加synchronized
,锁住的是该类的Class
实例。
死锁
1、Java的线程锁是可重入的锁。JVM允许同一个线程重复获取同一个锁,这种能被同一个线程反复获取的锁,就叫做可重入锁。由于Java的线程锁是可重入锁,所以,获取锁的时候,不但要判断是否是第一次获取,还要记录这是第几次获取。每获取一次锁,记录+1,每退出synchronized
块,记录-1,减到0的时候,才会真正释放锁。
2、死锁
public void add(int m) {
synchronized(lockA) { // 获得lockA的锁
this.value += m;
synchronized(lockB) { // 获得lockB的锁
this.another += m;
} // 释放lockB的锁
} // 释放lockA的锁
}
public void dec(int m) {
synchronized(lockB) { // 获得lockB的锁
this.another -= m;
synchronized(lockA) { // 获得lockA的锁
this.value -= m;
} // 释放lockA的锁
} // 释放lockB的锁
}
在获取多个锁的时候,不同线程获取多个不同对象的锁可能导致死锁。对于上述代码,线程1和线程2如果分别执行add()
和dec()
方法时:
- 线程1:进入
add()
,获得lockA
; - 线程2:进入
dec()
,获得lockB
。
随后:
- 线程1:准备获得
lockB
,失败,等待中; - 线程2:准备获得
lockA
,失败,等待中。
此时,两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。
死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程。
3、如何避免死锁?
线程获取锁的顺序要一致。即严格按照先获取lockA
,再获取lockB
的顺序。
使用wait和notify
1、在Java程序中,synchronized
解决了多线程竞争的问题。但是synchronized
并没有解决多线程协调的问题。多线程协调运行的原则就是:当条件不满足时,线程进入等待状态;当条件满足时,线程被唤醒,继续执行任务。
2、调用wait()
方法后,线程进入等待状态,wait()
方法不会返回,直到将来某个时刻,线程从等待状态被其他线程唤醒后,wait()
方法才会返回,然后,继续执行下一条语句。wait()
方法调用时,会释放线程获得的锁,wait()
方法返回后,线程又会重新试图获得锁。
3、如何让等待的线程被重新唤醒,然后从wait()
方法返回?答案是在相同的锁对象上调用notify()
方法。使用notifyAll()
将唤醒所有当前正在this
锁等待的线程,而notify()
只会唤醒其中一个(具体哪个依赖操作系统,有一定的随机性)。这是因为可能有多个线程正在getTask()
方法内部的wait()
中等待,使用notifyAll()
将一次性全部唤醒。通常来说,notifyAll()
更安全。有些时候,如果我们的代码逻辑考虑不周,用notify()
会导致只唤醒了一个线程,而其他线程可能永远等待下去醒不过来了。
public class TaskQueueDemo {
public static void main(String[] args) {
TaskQueue q = new TaskQueue();
List<Thread> ts = new ArrayList<Thread>();
for (int i = 0; i < 5; i++) {
Thread t = new Thread() {
public void run() {
//执行task:
while (true) {
try {
String s = q.getTask();
System.out.println("execute task: " + s);
} catch (InterruptedException e) {
return;
}
}
}
};
t.start();
ts.add(t);
}
}
}
//TaskQueue.java
public class TaskQueue {
Queue<String> queue = new LinkedList<>();
//往队列中添加任务
public synchronized void addTask(String s) {
this.queue.add(s);
this.notifyAll();//唤醒在this锁等待的线程
}
//取出队列的第一个任务
public synchronized String getTask() throws InterruptedException {
//如果队列为空,就循环等待,直到另一个线程往队列中放入了一个任务
while (queue.isEmpty()) {
// 释放this锁:
this.wait();
// 重新获取this锁
}
return queue.remove();
}
}
使用ReentrantLock
1、Java语言直接提供了synchronized
关键字用于加锁,但这种锁一是很重,二是获取时必须一直等待,没有额外的尝试机制。java.util.concurrent.locks
包提供的ReentrantLock
用于替代synchronized
加锁。
2、synchronized
是Java语言层面提供的语法,所以我们不需要考虑异常,而ReentrantLock
是Java代码实现的锁,我们就必须先获取锁,然后在finally
中正确释放锁。
public class CounterRelock {
private final Lock lock = new ReentrantLock();
private int count;
public void add(int n) {
lock.lock();
try {
count += n;
} finally {
lock.unlock();
}
}
}
3、ReentrantLock
是可重入锁,它和synchronized
一样,一个线程可以多次获取同一个锁。和synchronized
不同的是,ReentrantLock
可以尝试获取锁:
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
...
} finally {
lock.unlock();
}
}
上述代码在尝试获取锁的时候,最多等待1秒。如果1秒后仍未获取到锁,tryLock()
返回false
,程序就可以做一些额外处理,而不是无限等待下去。
所以,使用ReentrantLock
比直接使用synchronized
更安全,线程在tryLock()
失败的时候不会导致死锁。
使用Condition
1、背景
- 使用
ReentrantLock
比直接使用synchronized
更安全,可以替代synchronized
进行线程同步。 - 但是,
synchronized
可以配合wait
和notify
实现线程在条件不满足时等待,条件满足时唤醒,用ReentrantLock
我们怎么编写wait
和notify
的功能呢? - 答案是使用
Condition
对象来实现wait
和notify
的功能。
2、使用Condition
时,引用的Condition
对象必须从Lock
实例的newCondition()
返回,这样才能获得一个绑定了Lock
实例的Condition
实例。
public class TaskQueueRelock {
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
private Queue<String> queue = new LinkedList<>();
public void addTask(String s) {
lock.lock();
try {
queue.add(s);
condition.signalAll();
} finally {
lock.unlock();
}
}
public String getTask() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) {
condition.await();
}
return queue.remove();
} finally {
lock.unlock();
}
}
}
Condition
提供的await()
、signal()
、signalAll()
原理和synchronized
锁对象的wait()
、notify()
、notifyAll()
是一致的,并且其行为也是一样的。
3、和tryLock()
类似,await()
可以在等待指定时间后,如果还没有被其他线程通过signal()
或signalAll()
唤醒,可以自己醒来。
if (condition.await(1, TimeUnit.SECOND)) {
// 被其他线程唤醒
} else {
// 指定时间内没有被其他线程唤醒
}
使用ReadWriteLock
1、ReentrantLock
保证了只有一个线程可以执行临界区代码,但是有些时候,这种保护有点过头。实际上我们想要的是:允许多个线程同时读,但只要有一个线程在写,其他线程就必须等待。
2、使用ReadWriteLock
可以解决这个问题,它保证:
- 只允许一个线程写入(其他线程既不能写入也不能读取);
- 没有写入时,多个线程允许同时读(提高性能)。
3、把读写操作分别用读锁和写锁来加锁,在读取时,多个线程可以同时获得读锁,这样就大大提高了并发读的执行效率。使用ReadWriteLock
时,适用条件是同一个数据,有大量线程读取,但仅有少数线程修改。例如,一个论坛的帖子,回复可以看做写入操作,它是不频繁的,但是,浏览可以看做读取操作,是非常频繁的,这种情况就可以使用ReadWriteLock
。
public class CounterReWrLock {
private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
private final Lock rlock = rwlock.readLock();
private final Lock wlock = rwlock.writeLock();
private int[] counts = new int[10];
public void inc(int index) {
wlock.lock(); //加写锁
try {
counts[index] += 1;
} finally {
wlock.unlock(); //释放写锁
}
}
public int[] get() {
rlock.lock(); //加读锁
try {
return Arrays.copyOf(counts, counts.length);
} finally {
rlock.unlock(); //释放读锁
}
}
}
使用StampedLock
1、ReadWriteLock
可以解决多线程同时读,但只有一个线程能写的问题。它有个潜在的问题:如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的读锁。
2、StampedLock
和ReadWriteLock
相比,改进之处在于:读的过程中也允许获取写锁后写入!这样一来,我们读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁。
乐观锁的意思就是乐观地估计读的过程中大概率不会有写入,因此被称为乐观锁。反过来,悲观锁则是读的过程中拒绝有写入,也就是写入必须等待。显然乐观锁的并发效率更高,但一旦有小概率的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。
public class Point_StampedLock {
private final StampedLock stampedLock = new StampedLock();
private double x;
private double y;
private void move(double deltaX, double deltaY) {
long stamp = stampedLock.writeLock(); //获取写锁
try {
x += deltaX;
y += deltaY;
} finally {
stampedLock.unlockWrite(stamp); //释放写锁
}
}
public double distanceFromOrigin() {
//获得一个乐观读锁,返回版本号
long stamp = stampedLock.tryOptimisticRead();
// 注意下面两行代码不是原子操作
// 假设x,y = (100,200)
double currentX = x;
// 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
double currentY = y;
// 此处已读取到y,如果没有写入,读取是正确的(100,200)
// 如果有写入,读取是错误的(100,400)
if (!stampedLock.validate(stamp)) {// 检查乐观读锁后是否有其他写锁发生
//在读取过程中有写入,会验证失败时,通过悲观读锁再次读取
stamp = stampedLock.readLock(); //获取一个悲观读锁
try {
currentX = x;
currentY = y;
} finally {
stampedLock.unlockRead(stamp); //释放悲观读锁
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
}
通过validate()
去验证版本号,如果在读取过程中没有写入,版本号不变,验证成功,我们就可以放心地继续后续操作。如果在读取过程中有写入,版本号会发生变化,验证将失败。在失败的时候,我们再通过获取悲观读锁再次读取。由于写入的概率不高,程序在绝大部分情况下可以通过乐观读锁获取数据,极少数情况下使用悲观读锁获取数据。
可见,StampedLock
把读锁细分为乐观读和悲观读,能进一步提升并发效率。但这也是有代价的:一是代码更加复杂,二是**StampedLock
**是不可重入锁,不能在一个线程中反复获取同一个锁。StampedLock
还提供了更复杂的将悲观读锁升级为写锁的功能,它主要使用在if-then-update的场景:即先读,如果读的数据满足条件,就返回,如果读的数据不满足条件,再尝试写。
**
使用Concurrent集合
1、BlockingQueue
的意思就是说,当一个线程调用这个TaskQueue
的getTask()
方法时,该方法内部可能会让线程变成等待状态,直到队列条件满足不为空,线程被唤醒后,getTask()
方法才会返回。可以直接使用Java标准库的java.util.concurrent
包提供的线程安全的集合:ArrayBlockingQueue
。除了BlockingQueue
外,针对List
、Map
、Set
、Deque
等,java.util.concurrent
包也提供了对应的并发集合类。
interface | non-thread-safe | thread-safe |
---|---|---|
List | ArrayList | CopyOnWriteArrayList |
Map | HashMap | ConcurrentHashMap |
Set | HashSet / TreeSet | CopyOnWriteArraySet |
Queue | ArrayDeque / LinkedList | ArrayBlockingQueue / LinkedBlockingQueue |
Deque | ArrayDeque / LinkedList | LinkedBlockingDeque |
2、使用这些并发集合与使用非线程安全的集合类完全相同。因为所有的同步和加锁的逻辑都在集合内部实现,对外部调用者来说,只需要正常按接口引用,其他代码和原来的非线程安全代码完全一样。即当我们需要多线程访问时,可以把:
Map<String, String> map = new HashMap<>();
改为:
Map<String, String> map = new ConcurrentHashMap<>();
使用Atomic
1、Java的java.util.concurrent
包除了提供底层锁、并发集合外,还提供了一组原子操作的封装类,它们位于java.util.concurrent.atomic
包。
2、以AtomicInteger
为例,它提供的主要操作有:
- 增加值并返回新值:
int addAndGet(int delta)
- 加1后返回新值:
int incrementAndGet()
- 获取当前值:
int get()
- 用CAS方式设置:
int compareAndSet(int expect, int update)
3、Atomic类是通过无锁(lock-free)的方式实现的线程安全(thread-safe)访问。它的主要原理是利用了CAS:Compare and Set。