目录
Java多线程
多线程基础
现代操作系统(Windows、macOS、Linux)都可以执行多任务。多任务就是同时运行多个任务
CPU执行代码都是一条一条顺序执行的,但是,即使是单核CPU,也可以同时运行多个任务。因为操作系统执行多任务实际上就是让CPU对多个任务轮流交替执行
同一进程中的多条线程将共享该进程中的全部系统资源,如虚拟地址空间,文件描述符和信号处理等等。但同一进程中的多个线程有各自的调用栈(call stack),自己的寄存器环境(register context),自己的线程本地存储(thread-local storage)
进程(Process)
操作系统进行资源分配的基本单位,操作系统结构的基础。是程序的一次动态执行过程,它对应了从代码加载,执行完毕的一个完整过程;这个过程也是进程本身从产生、发展至消亡的过程
在计算机中,把一个任务称为一个进程,浏览器就是一个进程,视频播放器是另一个进程,类似的,音乐播放器和Word都是进程
(1)进程是系统运行程序的基本单位
(2)每个进程都有自己的独立的一块内存空间,一组系统资源
(3)每个进程的内部数据和状态都是完全独立的
线程(Thread)
操作系统能够进行运算调度的最小单位。被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
例如:在使用Word时,Word可以让一边打字,一边进行拼写检查,同时还可以在后台进行打印,把子任务称为线程
(1)进程执行时的最小单位、可完成一个独立的顺序控制流程
(2)每个进程中,至少建立一个线程(这个线程为主线程)作为程序入口
(3)如果在一个进程中运行多个线程,则称为“多线程”,多线程分享相同的地址空间并且共同分享一个进程
线程并非多个一起执行,由于单CPU的计算机只能执行一条命令;只是将CPU执行时间分成多个时间片,然后分步执行;只是上一个时间片与下一个时间片相隔非常短
操作系统调度的最小任务单位(线程)其实不是进程,而是线程。常用的Windows、Linux等操作系统都采用抢占式多任务,如何调度线程完全由操作系统决定,程序自己不能决定什么时候执行,以及执行多长时间
进程和线程关系:一个进程可以包含一个或多个线程,但至少会有一个线程
多线程好处?
(1)充分利用CPU资源
(2)简化编程模型
(3)带来良好的用户体验
进程与线程比较
和多线程相比,多进程的缺点在于:
(1)创建进程比创建线程开销大,尤其是在Windows系统上
(2)进程间通信比线程间通信要慢,因为线程间通信就是读写同一个变量,速度很快。
1.多进程
(1)多进程稳定性比多线程高,因为在多进程的情况下,一个进程崩溃不会影响其他进程,而在多线程的情况下,任何一个线程崩溃会直接导致整个进程崩溃
2.多线程
Java语言内置了多线程支持:一个Java程序实际上是一个JVM进程,JVM进程用一个主线程来执行main()方法,在main()方法内部,又可以启动多个线程。此外,JVM还有负责垃圾回收的其他工作线程等。因此,对于大多数Java程序来说的多任务,实际上是说如何使用多线程实现多任务
Java多线程编程的特点
多线程编程的特点在于:多线程经常需要读写共享数据,并且需要同步
(1)多线程模型是Java程序最基本的并发模型
(2)读写网络、数据库、Web开发等都依赖Java多线程模型
例如:播放电影时,就必须由一个线程播放视频,另一个线程播放音频,两个线程需要协调运行,否则画面和声音就不同步。因此,多线程编程的复杂度高,调试更困难
主线程(main入口)
(1)产生其他子线程的线程
通常他必须最后执行完成,因为它执行各种关闭动作
实现多任务方法(多进/线程,常规3种)
同一个应用程序,既可以有多个进程,也可以有多个线程
(1)多进程模式(每个进程只有一个线程)
(2)多线程模式(一个进程有多个线程)
(3)多进程 + 多线程模式(复杂度最高)
创建新线程(2种)
(1)Thread类(java.util.Thread)
public class Thread implements Runnable {}
构造方法
构造方法 | 描述 |
---|---|
Thread() | 创建一个Thread对象 |
Thread(String name) | 带有线程名字 |
Thread(Runnable target) | 具有target作为其运行对象 |
Thread(Runnable target, String name) | 具有target作为其运行对象,并且带有线程名字 |
Thread(ThreadGroup group, Runnable target) | 创建一个Thread组对象,具有target作为其运行对象 |
Thread(ThreadGroup group, String name) | 创建一个Thread组对象,带有线程名字 |
Thread(ThreadGroup group, Runnable target, String name) | 分配一个新的 Thread对象,使其具有 target作为其运行对象,具有指定的 name作为其名称,属于 group引用的线程组 |
Thread(ThreadGroup group, Runnable target, String name, long stackSize) | 分配一个新的Thread对象,以便它具有target作为其运行对象,将指定的name正如其名,以及属于该线程组由称作group,并具有指定的堆栈大小 |
常用方法
方法 | 描述 |
---|---|
void run() | 执行任务操作。仅主线程执行 |
void start() | 使线程开始执行,Java虚拟机调用该线程的run()。主/子交替执行 |
void sleep(long millis) | 在指定的毫秒数内让当前正在执行的线程休眠(暂停执行) |
String getName() | 返回线程的名称 |
static Thread currentThread() | 返回当前正在执行的线程对象引用 |
int getPriority() | 返回线程的优先级 |
void setPrioity(int newPrioity) | 更改线程优先级 |
void join() | 等待线程结束 |
void join(long mills) | 等待该线程终止的时间最长为mills毫秒 |
void yieId() | 暂停当前正在执行的线程对象(礼让),并执行其他线程;只是提供一种可能,但是不能保证一定会实现礼让 |
void interrupt() | 中断线程 |
boolean isAlive() | 测试线程是否处于活动状态 |
start与run区别(启动线程)
方法 | 描述 |
---|---|
run() | 只有主线程一条执行路径 |
start() | 多条执行路径,主线程和子线程并行交替执行 |
强制执行Join()
join()
join(long mills)
join(long mills,int nanos)
使用前:主线程与子线程交替执行
使用后:主线程暂停子线程运行,子线程完成后再运行主线程
参数 | 描述 |
---|---|
mills | 毫秒为单位的等待时长 |
nanos | 要等待的附加纳秒时长 |
需要处理InterruptException(中断)异常
sleep()属于Thread类,线程休眠,自动唤醒
wait()属于Object类,不会自动唤醒;使用notify()唤醒
wait和notify
在Java程序中,synchronized解决了多线程竞争的问题。例如,对于一个任务管理器,多个线程同时往队列中添加任务,可以用synchronized加锁;但是synchronized并没有解决多线程协调的问题
多线程协调运行的原则就是:当条件不满足时,线程进入等待状态;当条件满足时,线程被唤醒,继续执行任务。
public class Main {
public static void main(String[] args) throws InterruptedException {
var q = new TaskQueue();
var ts = new ArrayList<Thread>();
for (int i=0; i<5; i++) {
var 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);
}
var add = new Thread(() -> {
for(int i = 0; i<10; i++) {
// 放入task:
String s = "t-" + Math.random();
System.out.println("add task: " + s);
q.addTask(s);
try { Thread.sleep(100); } catch(InterruptedException e) {}
}
});
add.start();
add.join();
Thread.sleep(100);
for (var t : ts) {
t.interrupt();
}
}
}
class TaskQueue {
Queue<String> queue = new LinkedList<>();
public synchronized void addTask(String s) {
this.queue.add(s);
this.notifyAll();
}
public synchronized String getTask() throws InterruptedException {
while (queue.isEmpty()) {
this.wait();
}
return queue.remove();
}
}
重点关注addTask()方法,内部调用了this.notifyAll()而不是this.notify(),使用notifyAll()将唤醒所有当前正在this锁等待的线程,而notify()只会唤醒其中一个(具体哪个依赖操作系统,有一定的随机性)。这是因为可能有多个线程正在getTask()方法内部的wait()中等待,使用notifyAll()将一次性全部唤醒。通常来说,notifyAll()更安全。有些时候,如果我们的代码逻辑考虑不周,用notify()会导致只唤醒了一个线程,而其他线程可能永远等待下去醒不过来了。但是,注意到wait()方法返回时需要重新获得this锁。假设当前有3个线程被唤醒,唤醒后,首先要等待执行addTask()的线程结束此方法后,才能释放this锁,随后,这3个线程中只能有一个获取到this锁,剩下两个将继续等待
wait()方法的执行机制非常复杂。首先,它不是一个普通的Java方法,而是定义在Object类的一个native方法,也就是由JVM的C代码实现的。其次,必须在synchronized块中才能调用wait()方法,因为wait()方法调用时,会释放线程获得的锁,wait()方法返回后,线程又会重新试图获得锁。因此,只能在锁对象上调用wait()方法。因为在getTask()中,获得了this锁,因此,只能在this对象上调用wait()方法
public synchronized String getTask() {
while (queue.isEmpty()) {
// 释放this锁:
this.wait();
// 重新获取this锁
}
return queue.remove();
}
当一个线程在this.wait()等待时,它就会释放this锁,从而使得其他线程能够在addTask()方法获得this锁
让等待的线程被重新唤醒,然后从wait()方法返回,在相同的锁对象上调用notify()方法。修改addTask()
public synchronized void addTask(String s) {
this.queue.add(s);
this.notify(); // 唤醒在this锁等待的线程
}
注意:在往队列中添加了任务后,线程立刻对this锁对象调用notify()方法,这个方法会唤醒一个正在this锁等待的线程(就是在getTask()中位于this.wait()的线程),从而使得等待线程从this.wait()方法返回
notifyAll()将唤醒所有当前正在this锁等待的线程,而notify()只会唤醒其中一个(具体哪个依赖操作系统,有一定的随机性)
再注意到我们在while()循环中调用wait(),而不是if语句:
public synchronized String getTask() throws InterruptedException {
if (queue.isEmpty()) {
this.wait();
}
return queue.remove();
}
这种写法实际上是错误的,因为线程被唤醒时,需要再次获取this锁。多个线程被唤醒后,只有一个线程能获取this锁,此刻,该线程执行queue.remove()可以获取到队列的元素,然而,剩下的线程如果获取this锁后执行queue.remove(),此刻队列可能已经没有任何元素了,所以,要始终在while循环中wait(),并且每次被唤醒后拿到this锁就必须再次判断:
while (queue.isEmpty()) {
this.wait();
}
所以,正确编写多线程代码是非常困难的,需要仔细考虑的条件非常多,任何一个地方考虑不周,都会导致多线程运行时不正常
wait和notify用于多线程协调运行
(1)在synchronized内部可以调用wait()使线程进入等待状态
(2)必须在已获得的锁对象上调用wait()方法
(3)在synchronized内部可以调用notify()或notifyAll()唤醒其他等待线程
(4)必须在已获得的锁对象上调用notify()或notifyAll()方法
(5)已唤醒的线程还需要重新获得锁后才能继续执行
(2)Runnable接口
// 函数式接口
@FunctionalInterface
public interface Runnable {
public abstract void run();
}
Runnable接口非常简单,就定义了一个方法run(),继承Runnable并实现这个方法就可以实现多线程了,但是这个run()方法不能自己调用,必须由系统来调用
Runnable接口应由任何类实现,其实例将由线程执行。该类必须定义一个无参数的方法,称为run。在大多数情况下,应使用Runnable接口,如果只打算重写run()方法并没有其他Thread方法。 这是重要的,因为类不应该被子类化,除非打算修改或增强类的基本行为(大多数情况下实现Runnable接口实现多线程)
创建线程(实例)
(1)继承Thread类
从Thread派生一个自定义类,然后覆写run()方法
class MyThread extends Thread {
@Override
public void run() {
System.out.println("start new thread!");
}
}
public class Main {
public static void main(String[] args) {
Thread t = new MyThread();
t.start(); // 启动新线程;start()方法会在内部自动调用实例的run()方法
}
}
(2)实现Runnable接口(推荐)
创建Thread实例时,传入一个Runnable实例
class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("start new thread!");
}
}
public class Main {
public static void main(String[] args) {
Thread t = new Thread(new MyRunnable());
t.start(); // 启动新线程
}
}
匿名内部类(简化)
public static void main(String[] args) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("匿名内部类!");
}
});
thread.start();
}
使用Java8引入的lambda语法(再次简化)
public static void main(String[] args) {
new Thread(() -> {
for (int i = 0; i < 20; i++) {
System.out.println("一遍写程序");
}
}).start();
}
使用线程执行的打印语句,和直接在main()方法执行有区别吗?
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");
System.out.println("thread end");
}
};
t.start();
System.out.println("main end...");
}
}
输出:
main start...
main end...
thread run
thread end
线程的执行顺序:
(1)main线程肯定是先打印main start,再打印main end
(2)t线程肯定是先打印thread run,再打印thread end
但是,除了可以肯定,main start会先打印外。main end打印在thread run之前、thread end之后或者之间,都无法确定。因为从t线程开始运行以后,两个线程就开始同时运行了,并且由操作系统调度,程序本身无法确定线程的调度顺序
(1)Java用Thread对象表示一个线程,通过调用start()启动一个新线程
(2)一个线程对象只能调用一次start()方法
(3)线程的执行代码写在run()方法中
(4)线程调度由操作系统决定,程序本身无法决定调度顺序
(5)Thread.sleep()可以把当前线程暂停一段时间
线程的状态
在Java程序中,一个线程对象只能调用一次start()方法启动新线程,并在新线程中执行run()方法。一旦run()方法执行完毕,线程就结束了
Java线程状态(6个)
状态 | 描述 |
---|---|
New(出生) | 新创建的线程,尚未执行 |
Runnable(运行) | 运行中的线程,正在执行run()方法的Java代码 |
Blocked(暂停) | 运行中的线程,因为某些操作被阻塞而挂起 |
Waiting | 运行中的线程,因为某些操作在等待中 |
Timed Waiting | 运行中的线程,因为执行sleep()方法正在计时等待 |
Terminated(死亡) | 线程已终止,因为run()方法执行完毕 |
状态转移图
当线程启动后,它可以在Runnable、Blocked、Waiting和Timed Waiting这几个状态之间切换,直到最后变成Terminated状态,线程终止。
线程终止的原因
(1)线程正常终止:run()方法执行到return语句返回
(2)线程意外终止:run()方法因为未捕获的异常导致线程终止
(3)对某个线程的Thread实例调用stop()方法强制终止(强烈不推荐使用)
(4)线程直到其运行结束
线程生命周期(5个)
创建(出生)–> 就绪 --> 运行 --> 阻塞、死亡
状态转移形象化
守护线程(Daemon Thread)
Java程序入口就是由JVM启动main线程,main线程又可以启动其他线程。当所有线程都运行结束时,JVM退出,进程结束
如果有一个线程没有退出,JVM进程就不会退出。所以,必须保证所有线程都能及时结束
有一种线程的目的就是无限循环。例如:一个定时触发任务的线程
class TimerThread extends Thread {
@Override
public void run() {
while (true) {
System.out.println(LocalTime.now());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
break;
}
}
}
}
如果这个线程不结束,JVM进程就无法结束。问题是,由谁负责结束这个线程?
然而这类线程经常没有负责人来负责结束它们。但是,当其他线程结束时,JVM进程又必须要结束,要怎么办?就是使用守护线程(Daemon Thread)
守护线程指为其他线程服务的线程。在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。因此,JVM退出时,不必关心守护线程是否已结束
创建守护线程:setDaemon(true)
和普通线程一样,只是在调用start()方法前,调用setDaemon(true)把该线程标记为守护线程
Thread t = new MyThread();
// 标记此线程为守护线程
t.setDaemon(true);
t.start();
在守护线程中,编写代码要注意:守护线程不能持有任何需要关闭的资源,例如打开文件等,因为虚拟机退出时,守护线程没有任何机会来关闭文件,这会导致数据丢失
线程安全问题
当多个线程同时运行时,线程的调度由操作系统决定,程序本身无法决定。因此,任何一个线程都有可能在任何指令处被操作系统暂停,然后在某个时间段后继续执行
当多个线程共享同一资源时,一个线程未完成全部操作的时候,其他线程修改数据造成的不安全问题(多个线程操作共享资源时,引发的数据问题)
synchronized往下运行的前提:必须获取到锁。如果没有获取到锁,就等待其他线程交还锁,然后继续获取。获取到了才继续往下执行
synchronized方法控制对类成员变量的访问:每个类实例对应一把锁,每个synchronized方法都必须获得调用该方法的类实例的锁方能执行,否则所属线程阻塞,方法一旦执行,就独占该锁,直到从该方法返回时才将锁释放,此后被阻塞的线程方能获得该锁,重新进入可执行状态。这种机制确保了同一时刻对于每一个类实例,其所有声明为synchronized的成员函数中至多只有一个处于可执行状态(因为至多只有一个能够获得该类实例对应的锁),从而有效避免了类成员变量的访问冲突(只要所有可能访问类成员变量的方法均被声明为synchronized)
synchronized放在方法和在方法内synchronize(this)是等价的。都仅仅能锁住当前对象
①一个线程在访问一个对象的同步方法时,另一个线程可以同时访问这个对象的非同步方法
②一个线程在访问一个对象的同步方法时,另一个线程不能同时访问这个同步方法
③前提条件:多个线程所持有的对象锁共享且唯一,如果每个线程所持有的对象锁不一样,那么该对象是锁不住的
一个线程在访问一个对象的同步方法时,另一个线程不能同时访问这个对象的其他同步方法
例:如果一个线程正在执行同步方法syncMethodA(),另一个线程想访问这个对象里的同步方法syncMethodB(),则需要等待syncMethodA()执行完成
(1)作用于方法时,锁住的是对象的实例(this)
(2)作用于静态方法时,锁住的是Class实例,又因为Class的相关数据存储在永久带PermGen(jdk1.8则是metaspace),永久带是全局共享的,因此静态方法锁相当于类的一个全局锁,会锁所有调用该方法的线程
作用于一个静态类的时候,不管是不是本身内部的静态类,还是别人的静态类,都可以完成锁住的效果(ps 上锁的时候,相当于一群人 拿把锁找个东西上锁
synchronized作用于静态方法和非静态方法的区别:
非静态方法
给对象加锁(可以理解为给这个对象的内存上锁,注意:只是这块内存,其他同类对象都会有各自的内存锁),这时候在其他一个以上线程中执行该对象的这个同步方法(注意:是该对象)就会产生互斥
静态方法
相当于在类上加锁(*.class 位于代码区,静态方法位于静态区域,这个类产生的对象公用这个静态方法,所以这块内存,N个对象来竞争);这时候,只要是这个类产生的对象,在调用这个静态方法时都会产生互斥
注意:不能用synchronized修饰方法外面的语句块(类语句块),虽然可以在方法外面定义语句块,这样做会遇到编译错误,这里涉及到了Java里面的对象初始化的部分知识。大概的原因就是synchronized锁住的是对象,当初始化对象的时候,JVM在对象初始化完成之前会调用方法外面的语句块,这个时候对象还不存在,所以就不存在锁了
static方法和非static方法前面加synchronized到底有什么不同呢?
static的方法属于类方法,它属于这个Class(注意:这里的Class不是指Class的某个具体对象),那么static获取到的锁,就是当前调用这个方法的对象所属的类(Class,而不再是由这个Class产生的某个具体对象了)。而非static方法获取到的锁,就是当前调用这个方法的对象的锁了。所以,它们之间不会产生互斥
1.同步线程(同步代码块)
public class Main {
public static void main(String[] args) throws Exception {
var add = new AddThread();
var 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<10000; i++) { Counter.count += 1; }
}
}
class DecThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) { Counter.count -= 1; }
}
}
两个线程同时对一个int变量进行操作,一个加10000次,一个减10000次,最后结果应该是0,但是,每次运行,结果实际上都是不一样的。这是因为对变量进行读取和写入时,结果要正确,必须保证是原子操作。原子操作是指不能被中断的一个或一系列操作
多线程模型下,要保证逻辑正确,对共享变量进行读写时,必须保证一组指令以原子方式执行:即某一个线程执行时,其他线程必须等待
加锁和解锁的操作,就能保证3条指令总是在一个线程执行期间,不会有其他线程会进入此指令区间。即使在执行期线程被操作系统中断执行,其他线程也会因为无法获得锁导致无法进入此指令区间。只有执行线程将锁释放后,其他线程才有机会获得锁并执行。这种加锁和解锁之间的代码块我们称之为临界区(Critical Section),任何时候临界区最多只有一个线程能执行
synchronized保证了代码块在任意时刻最多只有一个线程能执行
synchronized(Counter.lock) { // 获取锁
//...
} // 释放锁
表示用Counter.lock实例作为锁,两个线程在执行各自的synchronized(Counter.lock) { … }代码块时,必须先获得锁,才能进入代码块进行。执行结束后,在synchronized语句块结束会自动释放锁。这样一来,对Counter.count变量进行读写就不可能同时进行。上述代码无论运行多少次,最终结果都是0
使用synchronized解决了多线程同步访问共享变量的正确性问题。但是,它的缺点是带来了性能下降。因为synchronized代码块无法并发执行。此外,加锁和解锁需要消耗一定的时间;所以,synchronized会降低程序的执行效率
如何使用synchronized
(1)找出修改共享变量的线程代码块
(2)选择一个共享实例作为锁
(3)使用synchronized(lockObject) { … }
在使用synchronized的时候,不必担心抛出异常。因为无论是否有异常,都会在synchronized结束处正确释放锁
public void add(int m) {
synchronized (Object) {
if (m < 0) {
throw new RuntimeException();
}
this.value += m;
} // 无论有无异常,都会在此释放锁
}
错误使用synchronized的例子
public class Main {
public static void main(String[] args) throws Exception {
var add = new AddThread();
var dec = new DecThread();
add.start();
dec.start();
add.join();
dec.join();
System.out.println(Counter.count);
}
}
class Counter {
public static final Object lock1 = new Object();
public static final Object lock2 = new Object();
public static int count = 0;
}
class AddThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock1) {
Counter.count += 1;
}
}
}
}
class DecThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock2) {
Counter.count -= 1;
}
}
}
}
结果并不是0,这是因为两个线程各自的synchronized锁住的不是同一个对象!这使得两个线程各自都可以同时获得锁:因为JVM只保证同一个锁在任意时刻只能被一个线程获取,但两个不同的锁在同一时刻可以被两个线程分别获取
因此,使用synchronized的时候,获取到的是哪个锁非常重要。锁对象如果不对,代码逻辑就不对
public class Main {
public static void main(String[] args) throws Exception {
var ts = new Thread[] { new AddStudentThread(),
new DecStudentThread(),
new AddTeacherThread(),
new DecTeacherThread() };
for (var t : ts) {
t.start();
}
for (var t : ts) {
t.join();
}
System.out.println(Counter.studentCount);
System.out.println(Counter.teacherCount);
}
}
class Counter {
public static final Object lock = new Object();
public static int studentCount = 0;
public static int teacherCount = 0;
}
class AddStudentThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.studentCount += 1;
}
}
}
}
class DecStudentThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.studentCount -= 1;
}
}
}
}
class AddTeacherThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.teacherCount += 1;
}
}
}
}
class DecTeacherThread extends Thread {
public void run() {
for (int i=0; i<10000; i++) {
synchronized(Counter.lock) {
Counter.teacherCount -= 1;
}
}
}
}
4个线程对两个共享变量分别进行读写操作,但是使用的锁都是Counter.lock这一个对象,这就造成了原本可以并发执行的Counter.studentCount += 1和Counter.teacherCount += 1,现在无法并发执行了,执行效率大大降低。
实际上,需要同步的线程可以分成两组:AddStudentThread和DecStudentThread、AddTeacherThread和DecTeacherThread,组之间不存在竞争,因此,应该使用两个不同的锁
AddStudentThread和DecStudentThread使用lockStudent锁
synchronized(Counter.lockStudent) {
...
}
AddTeacherThread和DecTeacherThread使用lockTeacher锁
synchronized(Counter.lockTeacher) {
...
}
这样才能最大化地提高执行效率
不需要synchronized的操作
JVM规范定义了几种原子操作:
(1)基本类型(long和double除外)赋值,例如:int n = m
(2)引用类型赋值,例如:List list = anotherList
long和double是64位数据,JVM没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把long和double的赋值作为原子操作实现的
单条原子操作的语句不需要同步
public void set(int m) {
//synchronized(lock) {
this.value = m;
//}
}
对引用也是类似
public void set(String s) {
this.value = s;
}
上述赋值语句并不需要同步。
但是,如果是多行赋值语句,就必须保证是同步操作,例如:
class Pair {
int first;
int last;
public void set(int first, int last) {
synchronized(this) {
this.first = first;
this.last = last;
}
}
}
有些时候,通过一些巧妙的转换,可以把非原子操作变为原子操作。例如,上述代码如果改造成:
class Pair {
int[] pair;
public void set(int first, int last) {
int[] ps = new int[] { first, last };
this.pair = ps;
}
}
就不再需要同步,因为this.pair = ps是引用赋值的原子操作
int[] ps = new int[] { first, last };
这里的ps是方法内部定义的局部变量,每个线程都会有各自的局部变量,互不影响,并且互不可见,并不需要同步
(1)多线程同时读写共享变量时,会造成逻辑错误,因此需要通过synchronized同步
(2)同步的本质就是给指定对象加锁,加锁后才能继续执行后续代码
(3)注意加锁对象必须是同一个实例
(4)对JVM定义的单个原子操作不需要同步
2.同步类/方法
Java程序依靠synchronized对线程进行同步,使用synchronized的时候,锁住的是哪个对象非常重要
让线程自己选择锁对象往往会使得代码逻辑混乱,也不利于封装。更好的方法是把synchronized逻辑封装起来
public class Counter {
private int count = 0;
public void add(int n) {
synchronized(this) {
count += n;
}
}
public void dec(int n) {
synchronized(this) {
count += n;
}
}
public int get() {
return count;
}
}
线程调用add()、dec()方法时,它不必关心同步逻辑,因为synchronized代码块在add()、dec()方法内部。
注意:synchronized锁住的对象是this,即当前实例,这又使得创建多个Counter实例的时候,它们之间互不影响,可以并发执行。
一个类被设计为允许多线程正确访问,就说这个类就是“线程安全”的(thread-safe)上面的Counter类就是线程安全的。Java标准库的java.lang.StringBuffer也是线程安全的;还有一些不变类,例如String,Integer,LocalDate,它们的所有成员变量都是final,多线程同时访问时只能读不能写,这些不变类也是线程安全的。最后,类似Math这些只提供静态方法,没有成员变量的类,也是线程安全的
除了上述几种少数情况,大部分类,例如:ArrayList,都是非线程安全的类,不能在多线程中修改它们。但是,如果所有线程都只读取,不写入,那么ArrayList是可以安全地在线程间共享的。
没有特殊说明时,一个类默认是非线程安全的
锁住的是this实例时,实际上可以用synchronized修饰这个方法。下面两种写法是等价的:
public void add(int n) {
synchronized(this) { // 锁住this
count += n;
} // 解锁
}
public synchronized void add(int n) { // 锁住this
count += n;
} // 解锁
因此,用synchronized修饰的方法就是同步方法,它表示整个方法都必须用this实例加锁
对一个静态方法添加synchronized修饰符,它锁住的是哪个对象?
public synchronized static void test(int n) {
// ...
}
对于static方法,是没有this实例的,因为static方法是针对类而不是实例。但是任何一个类都有一个由JVM自动创建的Class实例,因此,对static方法添加synchronized,锁住的是该类的class实例
上述synchronized static方法实际上相当于:
public class Counter {
public static void test(int n) {
synchronized(Counter.class) {
// ...
}
}
}
再考察Counter的get()方法:
public class Counter {
private int count;
public int get() {
return count;
}
// ...
}
它没有同步,因为读一个int变量不需要同步
然而,如果把代码稍微改一下,返回一个包含两个int的对象:
public class Counter {
private int first;
private int last;
public Pair get() {
Pair p = new Pair();
p.first = first;
p.last = last;
return p;
}
// ...
}
就必须要同步了
(1)用synchronized修饰方法可以把整个方法变为同步代码块,synchronized方法加锁对象是this
(2)通过合理的设计和数据封装可以让一个类变为“线程安全”
(3)一个类没有特殊说明,默认不是thread-safe
(4)多线程能否安全访问某个非线程安全的实例,需要具体问题具体分析
线程死锁
Java的线程锁是可重入的锁。
什么是可重入的锁?
对同一个线程,JVM允许同一个线程重复获取同一个锁,这种能被同一个线程反复获取的锁,就叫做可重入锁
public class Counter {
private int count = 0;
public synchronized void add(int n) {
if (n < 0) {
dec(-n);
} else {
count += n;
}
}
public synchronized void dec(int n) {
count += n;
}
}
观察synchronized修饰的add()方法,一旦线程执行到add()方法内部,说明它已经获取了当前实例的this锁。如果传入的n < 0,将在add()方法内部调用dec()方法。由于dec()方法也需要获取this锁
由于Java的线程锁是可重入锁。所以,获取锁的时候,不但要判断是否是第一次获取,还要记录这是第几次获取。每获取一次锁,记录 +1,每退出synchronized块,记录 -1,减到0的时候,才会真正释放锁
什么是死锁?
两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁
死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程
因此,在编写多线程应用时,要特别注意防止死锁。因为死锁一旦形成,就只能强制结束进程
一个线程可以获取一个锁后,再继续获取另一个锁。
public void add(int m) {
synchronized(locakA) { // 获得lockA的锁
this.value += m;
synchronized(lockB) { // 获得lockB的锁
this.another += m;
} // 释放lockB的锁
} // 释放lockA的锁
}
public void dec(int m) {
synchronized(lockB) { // 获得lockB的锁
this.another -= m;
synchronized(loackB) { // 获得lockA的锁
this.value -= m;
} // 释放lockA的锁
} // 释放lockB的锁
}
在获取多个锁的时候,不同线程获取多个不同对象的锁可能导致死锁。对于上述代码,线程1和线程2如果同时分别执行add()和dec()方法
如何避免死锁?
线程获取锁的顺序要一致。即严格按照先获取lockA,再获取lockB的顺序,改写dec()方法
(1)Java的synchronized锁是可重入锁
(2)死锁产生的条件是多线程各自持有不同的锁,并互相试图获取对方已持有的锁,导致无限等待
(3)避免死锁的方法是多线程获取锁的顺序要一致
public void dec(int m) {
synchronized(lockA) { // 获得lockA的锁
this.value -= m;
synchronized(lockB) { // 获得lockB的锁
this.another -= m;
} // 释放lockB的锁
} // 释放lockA的锁
}
ReentrantLock
public class ReentrantLock
implements Lock, java.io.Serializable {}
从Java 5开始,引入了一个高级的处理并发的java.util.concurrent包,它提供了大量更高级的并发功能,能大大简化多线程程序的编写
Java语言直接提供了synchronized关键字用于加锁,但这种锁①很重,②获取时必须一直等待,没有额外的尝试机制
java.util.concurrent.locks包提供的ReentrantLock用于替代synchronized加锁
一个可重入互斥Lock具有与使用synchronized方法和语句访问的隐式监视锁相同的基本行为和语义,但具有扩展功能
调用lock的线程将返回,成功获取锁,当锁不是由另一个线程拥有。如果当前线程已经拥有该锁,该方法将立即返回。这可以使用方法isHeldByCurrentThread()和getHoldCount()进行检查
该类的构造函数接受可选的公平参数。当设置true,在争用下,锁有利于授予访问最长等待的线程。否则,该锁不保证任何特定的访问顺序。使用许多线程访问的公平锁的程序可能会比使用默认设置的整体吞吐量(即,更慢,通常要慢得多),但是具有更小的差异来获得锁定并保证缺乏饥饿。 但是请注意,锁的公平性不能保证线程调度的公平性。因此,使用公平锁的许多线程之一可以连续获得多次,而其他活动线程不进行而不是当前持有锁。另请注意,未定义的tryLock()方法不符合公平性设置。如果锁可用,即使其他线程正在等待,它也会成功。
构造方法
构造方法 | 描述 |
---|---|
ReentrantLock() | 创建一个 ReentrantLock的实例 |
ReentrantLock(boolean fair) | 根据给定的公平政策创建一个ReentrantLock的实例 |
常用方法
方法 | 描述 |
---|---|
void lock() | 获得锁 |
void unlock() | 释放此锁 |
boolean tryLock() | 只有在调用时它不被另一个线程占用才能获取锁 |
boolean tryLock(long timeout, TimeUnit unit) | 如果在给定的等待时间内没有被另一个线程占用,并且当前线程尚未被保留,则获取该锁(interrupted) |
boolean isLocked() | 查询此锁是否由任何线程持有 |
String toString() | 返回一个标识此锁的字符串以及其锁定状态 |
boolean isFair() | 如果此锁的公平设置为true,则返回true |
int getHoldCount() | 查询当前线程对此锁的暂停数量 |
protected Thread getOwner() | 返回当前拥有此锁的线程,如果不拥有,则返回 null |
protected Collection getQueuedThreads() | 返回包含可能正在等待获取此锁的线程的集合 |
int getQueueLength() | 返回等待获取此锁的线程数的估计 |
boolean hasQueuedThreads() | 查询是否有线程正在等待获取此锁 |
void lockInterruptibly() | 获取锁定,除非当前线程是 interrupted |
使用ReentrantLock
1.传统的synchronized代码
public class Counter {
private int count;
public void add(int n) {
synchronized(this) {
count += n;
}
}
}
2.用ReentrantLock替代
public class Counter {
private final Lock lock = new ReentrantLock();
private int count;
public void add(int n) {
lock.lock();
try {
count += n;
} finally {
lock.unlock();
}
}
}
因为synchronized是Java语言层面提供的语法,所以不需要考虑异常,而ReentrantLock是Java代码实现的锁,就必须先获取锁,然后在finally中正确释放锁
顾名思义,ReentrantLock是可重入锁,它和synchronized一样,一个线程可以多次获取同一个锁。和synchronized不同的是,ReentrantLock可以尝试获取锁:
if (lock.tryLock(1, TimeUnit.SECONDS)) { // 尝试获取锁
try {
// ...
} finally {
lock.unlock();
}
}
上述代码在尝试获取锁的时候,最多等待1秒。如果1秒后仍未获取到锁,tryLock()返回false,程序就可以做一些额外处理,而不是无限等待下去
总结
使用ReentrantLock比直接使用synchronized更安全,线程在tryLock()失败的时候不会导致死锁
(1)ReentrantLock可以替代synchronized进行同步
(2)ReentrantLock获取锁更安全
(3)必须先获取到锁,再进入try {…}代码块,最后使用finally保证释放锁
(4)可以使用tryLock()尝试获取锁
配合Condition实现wait、notify
使用ReentrantLock比直接使用synchronized更安全,可以替代synchronized进行线程同步。但是,synchronized可以配合wait和notify实现线程在条件不满足时等待,条件满足时唤醒,用ReentrantLock怎么编写wait和notify的功能呢?
通过ReentrantLock和Condition实现
class TaskQueue {
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() {
lock.lock();
try {
while (queue.isEmpty()) {
condition.await();
}
return queue.remove();
} finally {
lock.unlock();
}
}
}
使用Condition时,引用的Condition对象必须从Lock实例的newCondition()返回,这样才能获得一个绑定了Lock实例的Condition实例
Condition提供的await()、signal()、signalAll()原理和synchronized锁对象的wait()、notify()、notifyAll()是一致的,并且其行为也是一样的
(1)await():释放当前锁,进入等待状态
(2)signal():唤醒某个等待线程
(3)signalAll():唤醒所有等待线程
(4)唤醒线程从await()返回后需要重新获得锁
此外,和tryLock()类似,await()可以在等待指定时间后,如果还没有被其他线程通过signal()或signalAll()唤醒,可以自己醒来
if (condition.await(1, TimeUnit.SECOND)) {
// 被其他线程唤醒
} else {
// 指定时间内没有被其他线程唤醒
}
可见,使用Condition配合Lock,可以实现更灵活的线程同步
(1)Condition可以替代wait和notify
(2)Condition对象必须从Lock对象获取
ReadWriteLock
ReentrantLock保证了只有一个线程可以执行临界区代码,但是有些时候,这种保护有点过头。因为发现,任何时刻,只允许一个线程修改,也就是调用inc()方法是必须获取锁。但是,get()方法只读取数据,不修改数据,它实际上允许多个线程同时调用。
public class Counter {
private final Lock lock = new ReentrantLock();
private int[] counts = new int[10];
public void inc(int index) {
lock.lock();
try {
counts[index] += 1;
} finally {
lock.unlock();
}
}
public int[] get() {
lock.lock();
try {
return Arrays.copyOf(counts, counts.length);
} finally {
lock.unlock();
}
}
}
实际上想要的是:允许多个线程同时读,但只要有一个线程在写,其他线程就必须等待
读 | 写 | |
---|---|---|
读 | 允许 | 不允许 |
写 | 不允许 | 不允许 |
使用ReadWriteLock可以解决这个问题,它保证
(1)只允许一个线程写入(其他线程既不能写入也不能读取)
(2)没有写入时,多个线程允许同时读(提高性能)
用ReadWriteLock实现这个功能十分容易。需要创建一个ReadWriteLock实例,然后分别获取读锁和写锁
public class Counter {
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(); // 释放读锁
}
}
}
把读写操作分别用读锁和写锁来加锁,在读取时,多个线程可以同时获得读锁,这样就大大提高了并发读的执行效率
使用ReadWriteLock时,适用条件是同一个数据,有大量线程读取,但仅有少数线程修改。
例如,一个论坛的帖子,回复可以看做写入操作,它是不频繁的,但是,浏览可以看做读取操作,是非常频繁的,这种情况就可以使用ReadWriteLock
(1)使用ReadWriteLock可以提高读取效率
(2)ReadWriteLock只允许一个线程写入
(3)ReadWriteLock允许多个线程在没有写入时同时读取
(4)ReadWriteLock适合读多写少的场景