Java线程之基本原理
一、多线程概述
1.1 进程和线程概述
- 进程:操作系统中的应用程序,一个进程就是一个应用程序。进程A和进程B的内存独立不共享资源。
- 线程:
CPU调度的最小单元,进程的一个执行流/指定单元,一个进程可以有多个线程。
PS:Java程序启动的时候,JVM就是一个进程,JVM会执行main方法,main方法就是主线程,同时会再启动一个垃圾回收线程(守护线程)GC进行垃圾回收。即:Java最少有两个线程并发,主线程main方法和守护线程GC。
1.2 线程之间的关系
在Java语言中,堆内存和方法区内存共享。但是栈内存独立,一个线程一个栈。假设启动10个线程,会有10个栈空间,每个栈和每个栈之间,互不干扰,各自执行各自的,这就是多线程并发。Java中之所以有多线程机制,目的就是为了提高程序的处理效率。
PS:火车站,可以看做是一个进程。火车站中的每一个售票窗口可以看做是一个线程。我在窗口1购票,你可以在窗口2购票,你不需要等我,我也不需要等你。所以多线程并发可以提高效率。
1.3 实现多线程的条件
多核CPU的可以真正的是实现多线程并发,例如4核CPU表示同一个时间点上,可以真正的有4个进程并发执行。
单核的CPU不能够做到真正的多线程并发,但是可以做到给人一种“多线程并发”的感觉,原因是CPU的运行速度很快。对于单核的CPU来说,在某一个时间点上实际上只能处理一件事情,但是由于CPU的处理速度极快,多个线程之间频繁切换执行,给别人的感觉是:多个事情同时在做!!
同时,多线程程序并不是同时进行的,由于CPU的执行速度太快,CPU会在不同的线程之间快速的切换执行,这个现象就是上下文切换,即:CPU从一个线程或进程切换到另一个线程或进程。
二、线程特性
2.1 共享性
数据共享性是线程安全的主要原因之一。如果所有的数据只是在线程内有效,那就不存在线程安全性问题,这也是我们在编程的时候经常不需要考虑线程安全的主要原因之一。
但是,在多线程编程中,数据共享是不可避免的。最典型的场景是数据库中的数据,为了保证数据的一致性,我们通常需要共享同一个数据库中数据,即使是在主从的情况下,访问的也同一份数据,主从只是为了访问的效率和数据安全,而对同一份数据做的副本。
我们现在,通过一个简单的示例来演示多线程下共享数据导致的问题:
代码段一:
public class ShareData {
public static int count = 0;
public static void main(String[] args) {
final ShareData data = new ShareData();
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
//进入的时候暂停1毫秒,增加并发问题出现的几率
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int j = 0; j < 100; j++) {
data.addCount();
}
System.out.print(count + " ");
}
}).start();
}
try {
//主程序暂停3秒,以保证上面的程序执行完成
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("count=" + count);
}
public void addCount() {
count++;
}
}
上述代码的目的是对count进行加一操作,执行1000次,不过这里是通过10个线程来实现的,每个线程执行100次,正常情况下,应该输出1000。不过,如果你运行上面的程序,你会发现结果却不是这样。下面是某次的执行结果(每次运行的结果不一定相同,有时候也可能获取到正确的结果):
100 200 300 400 500 600 735 853 953 753 count=953
可以看出,对共享变量操作,在多线程环境下很容易出现各种意想不到的的结果。
2.2 互斥性
资源互斥是指同时只允许一个访问者对其进行访问,具有唯一性和排它性。我们通常允许多个线程同时对数据进行读操作,但同一时间内只允许一个线程对数据进行写操作。
所以我们通常将锁分为共享锁和排它锁,也叫做读锁和写锁。如果资源不具有互斥性,即使是共享资源,我们也不需要担心线程安全。
例如,对于不可变的数据共享,所有线程都只能对其进行读操作,所以不用考虑线程安全问题。但是对共享数据的写操作,一般就需要保证互斥性,上述例子中就是因为没有保证互斥性才导致数据的修改产生问题。
Java中提供多种机制来保证互斥性,最简单的方式是使用Synchronized。现在我们在上面程序中加上Synchronized再执行:
代码段二:
public class ShareData {
public static int count = 0;
public static void main(String[] args) {
final ShareData data = new ShareData();
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
//进入的时候暂停1毫秒,增加并发问题出现的几率
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int j = 0; j < 100; j++) {
data.addCount();
}
System.out.print(count + " ");
}
}).start();
}
try {
//主程序暂停3秒,以保证上面的程序执行完成
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("count=" + count);
}
/**
* 增加synchronized关键字
*/
public synchronized void addCount() {
count++;
}
}
现在再执行上述代码,会发现无论执行多少次,返回的最终结果都是1000。
2.3 原子性
原子性就是指对数据的操作是一个独立的、不可分割的整体。换句话说,就是一次操作,是一个连续不可中断的过程,数据不会执行的一半的时候被其他线程所修改。保证原子性的最简单方式是操作系统指令,就是说如果一次操作对应一条操作系统指令,这样肯定可以能保证原子性。
但是很多操作不能通过一条指令就完成。例如,对long类型的运算,很多系统就需要分成多条指令分别对高位和低位进行操作才能完成。还比如,我们经常使用的整数i++的操作,其实需要分成三个步骤:
- 读取整数
i的值; - 对
i进行加一操作; - 将结果写回内存。
这个过程在多线程下就可能出现如下现象:

这也是代码段一执行的结果为什么不正确的原因。对于这种组合操作,要保证原子性,最常见的方式是加锁,如Java中的Synchronized或Lock都可以实现,代码段二就是通过Synchronized实现的。
除了锁以外,还有一种方式就是CAS(Compare And Swap),即修改数据之前先比较与之前读取到的值是否一致,如果一致,则进行修改,如果不一致则重新执行,这也是乐观锁的实现原理。不过CAS在某些场景下不一定有效,比如另一线程先修改了某个值,然后再改回原来值,这种情况下,CAS是无法判断的。
2.4 可见性
要理解可见性,需要先对JVM的内存模型有一定的了解,JVM的内存模型与操作系统类似,如图所示:

从这个图中我们可以看出,每个线程都有一个自己的工作内存(相当于CPU高级缓冲区,这么做的目的还是在于进一步缩小存储系统与CPU之间速度的差异,提高性能),对于共享变量,线程每次读取的是工作内存中共享变量的副本,写入的时候也直接修改工作内存中副本的值,然后在某个时间点上再将工作内存与主内存中的值进行同步。这样导致的问题是,如果线程1对某个变量进行了修改,线程2却有可能看不到线程1对共享变量所做的修改。通过下面这段程序我们可以演示一下不可见的问题:
public class VisibilityTest {
private static boolean ready;
private static int number;
private static class ReaderThread extends Thread {
public void run() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
if (!ready) {
System.out.println(ready);
}
System.out.println(number);
}
}
private static class WriterThread extends Thread {
public void run() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
number = 100;
ready = true;
}
}
public static void main(String[] args) {
new WriterThread().start();
new ReaderThread().start();
}
}
从直观上理解,这段程序应该只会输出100,ready的值是不会打印出来的。实际上,如果多次执行上面代码的话,可能会出现多种不同的结果,下面是我运行出来的某两次的结果:
false
100
-----
true
100
当然,这个结果也只能说是有可能是可见性造成的,当写线程(WriterThread)设置ready=true后,读线程(ReaderThread)看不到修改后的结果,所以会打印false,对于第二个结果,也就是执行if(!ready)时还没有读取到写线程的结果,但执行System.out.println(ready)时读取到了写线程执行的结果。不过,这个结果也有可能是线程的交替执行所造成的。Java中可通过Synchronized或Volatile来保证可见性。
2.5 有序性
为了提高性能,编译器和处理器可能会对指令做重排序。重排序可以分为三种:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(
Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。 - 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
我们可以直接参考一下JSR 133中对重排序问题的描述:

先看上图中的源码左部分,从源码来看,要么指令1先执行要么指令3先执行。如果指令1先执行,r2不应该能看到指令4中写入的值。如果指令3先执行,r1不应该能看到指令2写的值。但是运行结果却可能出现r2==2,r1==1的情况,这就是“重排序”导致的结果。上图右部分即是一种可能出现的合法的编译结果,编译后,指令1和指令2的顺序可能就互换了。因此,才会出现r2==2,r1==1的结果。Java中也可通过Synchronized或Volatile来保证顺序性。
三、线程的实现方法
3.1 继承Thread类
public class ThreadTest {
public static void main(String[] args) {
// 启动线程
new MyThread().start();
// 直接调用run()方法
// new MyThread().run();
// 主线程运行的程序
for (int i = 0; i < 1000; i++) {
System.out.println("主线程--->" + i);
}
}
}
class MyThread extends Thread {
@Override
public void run() {
// 编写程序,这段程序运行在分支线程中(分支栈)。
for (int i = 0; i < 1000; i++) {
System.out.println("分支线程--->" + i);
}
}
}
run()方法不会启动线程,只是普通的调用方法而已,不会分配新的分支栈(这种方式就是单线程)。start()方法的作用是:启动一个分支线程,在JVM中开辟一个新的栈空间,这段代码任务完成之后,瞬间就结束了。因此start()方法只是为了开启一个新的栈空间,只要新的栈空间开出来,start()方法就结束了,线程就启动成功了。
启动成功的线程会自动调用run()方法,并且run()方法在分支栈的栈底部(压栈)。run方法在分支栈的栈底部,main方法在主栈的栈底部。
run和main是平级的。
调用run()方法内存图如下

调用start()方法内存图如下

3.2 实现Runnable接口
这种方式相对于第一种方式,只是多了一个线程对象进行初始化,因为Thread的有参构造可以实现,其他的地方没有过多的变化。
/**
* 1. 创建类实现Runnable接口
*/
class MyRunnable implements Runnable {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
System.out.println("分支线程->" + i);
}
}
}
public class CreateThread {
public static void main(String[] args) {
// 启动线程
new Thread(new MyRunnable()).start();
// 主线程程序
for (int i = 0; i < 100; i++) {
System.out.println("主线程->" + i);
}
/**
* 2. 通过匿名内部类实现
*/
new Thread(() -> {
for (int i = 0; i < 100; i++) {
System.out.println("分支线程->" + i);
}
}).start();
}
}
3.3 实现Callable接口
Callable接口类似于Runnable,但是Runnable不会返回结果,并且无法抛出经过检查的异常,而Callable在不使用线程池的时候依赖FutureTask类获取返回结果。
单个线程池:使用ExecutorService、Callable、Future实现有返回结果的线程。
ExecutorService提供了submit()方法,传递一个Callable,或Runnable,返回Future。如果Executor后台线程池还没有完成Callable的计算,这调用返回Future对象的get()方法,会阻塞直到计算完成。
3.3.1 不使用线程池实现
/**
* 实现Callable接口
*/
class MyCallable implements Callable<Integer> {
@Override
public Integer call() {
int sum = 0;
for (int i = 0; i < 100; i++) {
sum += i;
}
return sum;
}
}
public class CreateThread {
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 启动线程
new Thread(new FutureTask<>(new MyCallable()), "方式一").start();
// FutureTask futureTask = new FutureTask<>(new MyCallable());
// new Thread(futureTask).start();
// 通过futureTask.get()获取返回值
System.out.println(futureTask.get());
/**
* 通过匿名内部类实现
*/
new Thread(new FutureTask<>(() -> {
int sum = 0;
for (int i = 0; i < 100; i++) {
sum += i;
}
return sum;
}));
// 主线程程序
for (int i = 0; i < 100; i++) {
System.out.println("主线程->" + i);
}
}
}
3.3.2 使用单个线程池实现
/**
* 实现Callable接口
*/
class MyCallable implements Callable<Integer> {
@Override
public Integer call() {
int sum = 0;
for (int i = 0; i < 100; i++) {
sum += i;
}
return sum;
}
}
public class CreateThread {
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 主线程程序
for (int i = 0; i < 100; i++) {
System.out.println("主线程->" + i);
}
/**
* 使用单线程池实现
*/
// 1. 创建固定大小的线程池对象
ExecutorService executorService = Executors.newFixedThreadPool(1);
// 2. 提交线程任务,通过Future接口接受返回的结果
Future<Integer> submit = executorService.submit(new MyCallable());
// 3. 关闭线程池
executorService.shutdown();
// 4. 调用future.get()获取callable执行完成的返回结果
System.out.println(submit.get());
}
}
3.4 三种方式的优缺点总结如下:
- 集成
Thread类- 优点:代码书写比较简单(实际也没有简单多少)
- 缺点:由于
Java的单继承性,导致后期无法继承其他的类,同时代码的耦合度比较高
- 实现
Runnable接口和Callable接口- 优点:适合多个相同的程序代码的线程去处理同一个资源,避免了
Java单继承的限制,代码可以被多个线程共享,代码和数据独立,同时线程池只能放入实现Runnable或Callable类线程,不能直接放入继承Thread的类。 - 缺点:通过匿名内部类进行实现,虽然代码书写简单一点,但是只适合线程使用一次的时候
- 优点:适合多个相同的程序代码的线程去处理同一个资源,避免了
四、线程调度和状态装换
线程的生命周期

4.1 线程的状态转换
New(新建状态):新创建了一个线程对象。Runnable(就绪状态):线程对象创建后,其他线程调用了该对象的start()方法。该状态的线程位于可运行线程池中,变得可运行,等待获取CPU的使用权。Running(运行状态):就绪状态的线程获取了CPU,执行程序代码。Blocked(阻塞状态):阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。阻塞的情况分三种:- 等待阻塞:运行的线程执行
wait()方法,JVM会把该线程放入等待池中。(wait会释放持有的锁) - 同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则
JVM会把该线程放入锁池中。 - 其他阻塞:运行的线程执行
sleep()或join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。(注意sleep是不会释放持有的锁)
- 等待阻塞:运行的线程执行
Dead(死亡状态):线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

4.2 线程调度
Java线程有优先级,优先级高的线程会获得较多的运行机会,因此通过Thread类的setPriority()和getPriority()方法分别用来设置和获取线程的优先级。
JVM提供了10个线程优先级,但与常见的操作系统都不能很好的映射。如果希望程序能移植到各个操作系统中,应该仅仅使用Thread类有以下三个静态常量作为优先级,这样能保证同样的优先级采用了同样的调度方式。其中,主程序使用的是NORM_PRIORITY,即5,同时还有MAX_PRIORITY=10和MIN_PRIORITY=1的静态优先级常量。
- 线程睡眠:
Thread.sleep(long millis)方法,使线程转到阻塞状态。millis参数设定睡眠的时间,以毫秒为单位。当睡眠结束后,就转为Runnable(就绪状态)。sleep()平台移植性好。 - 线程等待:
Object类中的wait()方法,导致当前的线程等待,直到其他线程调用此对象的notify()方法或notifyAll()唤醒方法。这个两个唤醒方法也是Object类中的方法,行为等价于调用wait(0)一样。 - 线程让步:
Thread.yield()方法,暂停当前正在执行的线程对象,把执行机会让给相同或者更高优先级的线程。 - 线程加入:
join()方法,等待其他线程终止。在当前线程中调用另一个线程的join()方法,则当前线程转入阻塞状态,直到另一个进程运行结束,当前线程再由阻塞转为就绪状态。 - 线程唤醒:
Object类中的notify()方法,唤醒在此对象监视器上等待的单个线程。如果所有线程都在此对象上等待,则会选择唤醒其中一个线程。选择是任意性的,并在对实现做出决定时发生。线程通过调用其中一个wait方法,在对象的监视器上等待。直到当前的线程放弃此对象上的锁定,才能继续执行被唤醒的线程。被唤醒的线程将以常规方式与在该对象上主动同步的其他所有线程进行竞争;例如,唤醒的线程在作为锁定此对象的下一个线程方面没有可靠的特权或劣势。类似的方法还有一个notifyAll(),唤醒在此对象监视器上等待的所有线程。
4.2.1 线程终止
线程在正常的程序中启动和停止,不需要额外的停止方式,会自动停止。但是有些情况下,有一些伺服线程还在运行,他们运行时间较长,只有当外部条件满足时,他们才会停止。针对这样的情况,提供了如下几种停止线程的方式:
1.使用标志位(推荐使用)
public class ThreadStopUse {
public static void main(String[] args) {
FlagStop flagStop = new FlagStop();
new Thread(flagStop).start();
for (int i = 0; i < 100; i++) {
System.out.println("主线程运行的第" + i + "次");
if (i == 90) {
// 调用自己的stop方法切换标志位,停止线程
flagStop.stop();
System.out.println("分支线程该停止了");
}
}
}
}
class FlagStop implements Runnable {
/**
* 定义标志
*/
private volatile boolean exitFlag = true;
/**
* 标志转换
*/
public void stop() {
this.exitFlag = false;
}
@Override
public void run() {
int i = 0;
while (exitFlag) {
System.out.println("分支线程运行的第" + i + "次");
}
}
}
2.使用interrupted()方法(不推荐)
使用interrupted()方法来中断线程有两种情况:
- 线程处于阻塞状态时,如线程中使用了
sleep(),同步锁wait(),socket的receiver,accept方法时,会使线程进入到阻塞状态,当程序调用interrupted()方法时,会抛出InterruptedException异常。阻塞中的那个方法抛出异常,通过捕获该异常,然后break跳出循环,从而结束该线程。注:不是调用了interrupted()方法就会结束线程,是捕获到了interruptedException异常后,break跳出循环后才能结束此线程。 - 线程未处于阻塞状态,调用
interrupted()方法时,实际上是通过判断线程的中断标记来退出循环。
class InterruptedStop implements Runnable {
@Override
public void run() {
for (int i = 0; i <= 200; i++) {
// 判断是否被中断,通过检查标志位
if (Thread.currentThread().isInterrupted()) {
// 处理中断逻辑
break;
}
System.out.println("i=" + i);
}
}
}
public class ThreadStopUse {
public static void main(String[] args) throws InterruptedException {
InterruptedStop interruptedStop = new InterruptedStop();
Thread thread = new Thread(interruptedStop);
thread.start();
Thread.sleep(1000);
thread.interrupt();
}
}
注意:在上面这段代码中,我们增加了
Thread.isInterrupted()来判断当前线程是否被中断了,如果是,则退出for循环,结束线程。
这种方式看起来与之前介绍的“使用标志位终止线程”非常类似,但是在遇到sleep()或者wait()这样的操作,我们只能通过中断来处理了。
3.使用stop()方法停止(强烈不推荐)
使用Thread.stop()方法来结束线程的运行是很危险的,主要因为在程序调用Thread.stop()后会抛出ThreadDeathError()错误,并释放子线程所持有的所有锁,会导致被保护数据呈现不一致性,此过程不可控。
4.2.2 线程休眠
线程休眠是Thread.sleep(ms)方法,它的作用是让当前线程进入休眠,进入“阻塞状态”,放弃占有CPU时间片,让给其它线程使用。执行效果就是间隔特定的时间,去执行一段特定的代码,每隔多久执行一次。millis参数设定睡眠的时间,以毫秒为单位。当睡眠结束后,就转为Runnable(就绪状态)。
注意:每个对象都有一个锁,
sleep()方法不会释放锁。
public class ThreadSleepUse {
public static void main(String[] args) {
while (true) {
try {
Thread.sleep(1000);
// 每隔一秒打印一下系统当前时间
System.out.println(new SimpleDateFormat("HH:mm:ss")
.format(new Date(System.currentTimeMillis())));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
4.2.3 线程礼让
暂停当前正在执行的线程对象,但不阻塞,将线程从运行状态转为就绪状态,把执行机会让给相同或者更高优先级的线程。让CPU重新调度,但是礼让不一定成功,因为当前线程和其他线程一同竞争CPU,使得所有线程回到同一起点,优先级高的线程获得的运行机会会多一点,这个过程不会释放锁。
public class ThreadYieldUse {
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
System.out.println("主线程执行了第" + i);
}
new Thread(() -> {
for (int i = 0; i < 20; i++) {
System.out.println(Thread.currentThread().getName() + "执行了" + i + "次");
if (i % 5 == 0) {
Thread.yield();
System.out.println("线程礼让,重新争抢CPU");
}
}
}, "线程礼让").start();
}
}
4.2.4 线程加入
线程加入就是在当前线程中调用另一个线程的join()方法,则当前线程转入阻塞状态,直到另一个进程运行结束,当前线程再由阻塞转为就绪状态。
将一个线程合并到当前线程中,当前线程受阻塞,加入的线程执行直到结束,这个是无参join()方法的作用,使用join(long millis)方法则等待该线程终止的时间最长为millis毫秒;使用join(long millis, int nanos)方法则等待该线程终止的时间最长为millis毫秒 + nanos纳秒。
作用:一个执行完的线程需要另一个正在执行的线程的运行结果时
public class ThreadJoinUse {
public static void main(String[] args) {
Thread thread = new Thread(() -> {
for (int j = 0; j < 50; j++) {
System.out.println("VIP线程-" + Thread.currentThread().getName() + "执行了" + j + "次");
}
}, "线程加入");
thread.start();
for (int i = 0; i < 100; i++) {
System.out.println(Thread.currentThread().getName() + "执行了" + i + "次");
if (i == 50) {
try {
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
五、线程同步
5.1 线程安全和线程同步概述
5.1.1 线程安全
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象就是线程安全的。
问题:通常情况下,一个进程中的比较耗时的操作(如长循环、文件上传下载、网络资源获取等),往往会采用多线程来解决。又比如实际生活中,银行取钱问题、火车票多个售票窗口的问题,通常会涉及到并发的问题,从而需要多线程的技术。
当进程中有多个并发线程进入一个重要数据的代码块时,在修改数据的过程中,很有可能引发线程安全问题,从而造成数据异常。例如,正常逻辑下,同一个编号的火车票只能售出一次,却由于线程安全问题而被多次售出,从而引起实际业务异常。
线程安全问题产生的原因——共享内存数据,当多个线程同时操作同一共享数据时,导致共享数据出错。
线程、主内存、工作内存三者的关系如图:

在Java内存模型中,分为主内存和线程工作内存。每条线程有自己的工作内存,线程使用共享数据时,都是先从主内存中拷贝到工作内存,线程对该变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量,线程使用完成之后再写入主内存。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
在多线程环境下,不同线程对同一份数据操作,就可能会产生不同线程中数据状态不一致的情况,这就是线程安全问题的原因。
5.1.2 线程同步
当多线程并发的环境下,有共享数据,并且这个数据还会被修改,此时就存在线程安全问题,怎么解决这个问题?
要实现线程安全,需要保证数据操作的两个特性:
- 原子性:对数据的操作不会受其他线程打断,意味着一个线程操作数据过程中不会插入其他线程对数据的操作。
- 可见性:当线程修改了数据的状态时,能够立即被其他线程知晓,即数据修改后会立即写入主内存,后续其他线程读取时就能得知数据的变化。
以上两个特性结合起来,其实就相当于同一时刻只能有一个线程去进行数据操作并将结果写入主存,这样就保证了线程安全,这种机制称为线程同步
线程同步就是线程不能并发,线程必须排队执行,因此线程同步会牺牲一部分的效率,来提升安全性。
线程排队执行(不能并发),用排队执行解决线程安全问题。
实现方式:
- 通过
Synchronized关键字修饰代码块或者方法,一个线程访问一个对象中的synchronized(this)同步代码块时,其他试图访问该对象的线程将被阻塞。 Lock锁,支持那些语义不同(重入、公平等)的锁规则,可以在非阻塞式结构的上下文(包括hand-over-hand和锁重排算法)中使用这些规则。
5.2 线程同步的实现方式
5.2.1 synchronized锁
synchronized是Java中的关键字,是一种同步锁。它修饰的对象有以下几种:
- 修饰一个代码块,被修饰的代码块称为实例代码块,其作用的范围是大括号{}括起来的代码,锁是
synchronized括号里配置的对象;如果作用在静态方法中,则称为静态代码块,锁对象是当前类的字节码文件; - 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,锁这个方法所在的当前实例对象;
- 修改一个静态的方法,其作用的范围是整个静态方法,锁是这个类的所有对象;
- 修改一个类,其作用的范围是
synchronized后面括号括起来的部分,锁是这个类的所有对象。
修饰一个代码块
一个线程访问一个对象中的synchronized(this)同步代码块时,其他试图访问该对象的线程将被阻塞。
public class ThreadSafety {
public static void main(String[] args) {
System.out.println("使用关键字synchronized");
SyncThread syncThread = new SyncThread();
new Thread(syncThread, "SyncThread1").start();
new Thread(syncThread, "SyncThread2").start();
}
}
class SyncThread implements Runnable {
private static int count = 0;
@Override
public void run() {
synchronized (this) {
for (int i = 0; i < 5; i++) {
try {
System.out.println("线程名:" + Thread.currentThread().getName() + ":" + (count++));
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
// 其他逻辑
}
}
当两个并发线程(thread1和thread2)访问同一个对象(syncThread)中的synchronized代码块时,在同一时刻只能有一个线程得到执行,另一个线程受阻塞,必须等待当前线程执行完这个代码块以后才能执行该代码块。Thread1和thread2是互斥的,因为在执行synchronized代码块时会锁定当前的对象,只有执行完该代码块才能释放该对象锁,下一个线程才能执行并锁定该对象。
注:
synchronized只锁定对象,多个线程要实现同步,所以线程必须以同一个Runnable对象为运行对象,即:()中的对象要是同一个
这时如果创建了两个SyncThread的对象syncThread1和syncThread2,线程thread1执行的是syncThread1对象中的synchronized代码(run),而线程thread2执行的是syncThread2对象中的synchronized代码(run);我们知道synchronized锁定的是对象,这时会有两把锁分别锁定syncThread1对象和syncThread2对象,而这两把锁是互不干扰的,不形成互斥,所以两个线程可以同时执行。
当一个线程访问对象的一个synchronized(this)同步代码块时,另一个线程仍然可以访问该对象中的非synchronized(this)同步代码块。
如果synchronized作用在静态方法中,修饰一块代码,则称为静态代码块,锁对象是当前类的字节码文件。
class SyncThread implements Runnable {
private static int count = 0;
/**
* synchronized作用在静态方法中,锁对象实当前类的字节码文件
*/
public static void save() {
synchronized (SyncThread.class) {
count++;
}
// 其他操作
}
}
修饰一个方法
Synchronized修饰一个方法很简单,就是在方法的前面加synchronized,synchronized修饰方法和修饰一个代码块类似,只是作用范围不一样,修饰代码块是大括号括起来的范围,而修饰方法范围是整个函数。
class SyncThread implements Runnable {
private static int account = 100;
/**
* synchronized修饰一个方法,被修饰的方法称为同步方法,
* 其作用的范围是整个方法,锁对象为这个方法所在的当前实例对象
* @param money
*/
public synchronized void draw(Integer money) {
account -= money;
}
}
在用synchronized修饰方法时要注意以下几点:
synchronized关键字不能继承。虽然可以使用synchronized来定义方法,但synchronized并不属于方法定义的一部分,因此,synchronized关键字不能被继承。如果在父类中的某个方法使用了synchronized关键字,而在子类中覆盖了这个方法,在子类中的这个方法默认情况下并不是同步的,而必须显式地在子类的这个方法中加上synchronized关键字才可以。- 当然,还可以在子类方法中调用父类中相应的方法,这样虽然子类中的方法不是同步的,但子类调用了父类的同步方法,因此,子类的方法也就相当于同步了。
- 在定义接口方法时不能使用
synchronized关键字。 - 构造方法不能使用
synchronized关键字,但可以使用synchronized代码块来进行同步。
修饰静态方法
静态方法是属于类的而不属于对象的,synchronized修饰的静态方法锁定的是这个类的所有对象,该类的所有对象用synchronized修饰的静态方法的用的是同一把锁。
修饰一个类
效果和synchronized修饰静态方法是一样的,synchronized作用于一个类时,是给这个类加锁,该类的所有对象用的是同一把锁。
5.2.2 Lock锁
Lock和ReadWriteLock锁简介
从JDK5.0开始,Java提供了更强大的线程同步机制——通过显式定义同步锁对象来实现同步,Lock和ReadWriteLock是两大锁的根接口,Lock代表实现类是ReentrantLock(可重入锁),ReadWriteLock(读写锁)的代表实现类是ReentrantReadWriteLock。
Lock接口支持那些语义不同(重入、公平等)的锁规则,可以在非阻塞式结构的上下文(包括hand-over-hand和锁重排算法)中使用这些规则,是控制多个线程对共享资源进行访问的工具。Lock锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象。主要的实现是ReentrantLock,ReentrantLock类实现了Lock,它拥有与synchronized相同的并发性和内存语义,在实现线程安全的控制中,比较常用的是ReentrantLock,可以显式加锁、释放锁。ReadWriteLock接口以类似方式定义了一些读取者可以共享而写入者独占的锁。此包只提供了一个实现,即ReentrantReadWriteLock。但程序员可以创建自己的、适用于非标准要求的实现。
Lock锁与synchronized锁比较
synchronized是隐式锁,出了作用域自动释放,锁的控制和释放是在synchronized同步代码块的开始和结束位置。而Lock是显示锁,锁的开启和关闭都是手动的,实现同步时,锁的获取和释放可以在不同的代码块、不同的方法中。Lock只有代码块锁,而synchronized有代码块锁和方法锁。Lock接口提供了试图获取锁的tryLock()方法,在调用tryLock()获取锁失败时返回false,这样线程可以执行其它的操作而不至于使线程进入休眠。tryLock()方法可传入一个long型的时间参数,允许在一定的时间内来获取锁。Lock接口的实现类ReentrantReadWriteLock提供了读锁和写锁,允许多个线程获得读锁、而只能有一个线程获得写锁,读锁和写锁不能同时获得。实现了读和写的分离,这一点在需要并发读的应用中非常重要,如lucene允许多个线程读取索引数据进行查询但只能有一个线程负责索引数据的构建。- 基于以上几点,使用
lock锁,JVM会花费更少的时候来调度线程,因此性能较好,同时有更好的可扩展性(提供更多的子类)。
Lock独有特征
- 尝试非阻塞的获取锁:当前线程尝试获取锁,如果这一时刻锁没有被其他线程获取到,则成功获取并持有锁。
- 能被中断的获取锁:获取到锁的线程能够响应中断,当获取到锁的线程被中断时,中断异常将会被抛出,同时锁会被释放。
- 超时获取锁:在指定的截止时间之前获取锁,超过截止时间后仍旧无法获取则返回。
Lock锁的使用场景
如果一个代码块被synchronized关键字修饰,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待直至占有锁的线程释放锁。
事实上,占有锁的线程释放锁一般会是以下三种情况之一:
- 占有锁的线程执行完了该代码块,然后释放对锁的占有;
- 占有锁线程执行发生异常,此时JVM会让线程自动释放锁;
- 占有锁线程进入waiting状态从而释放锁,例如在该线程中调用wait()方法等。
以下三种场景只能用Lock:
- 使用synchronized关键字的情形下,假如占有锁的线程由于要等待IO或者其他原因(比如调用sleep方法)被阻塞了,但是又没有释放锁,那么其他线程就只能一直等待,别无他法。这会极大影响程序执行效率。因此,就需要有一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间(解决方案:tryLock(longtime, TimeUnit unit))或者能够响应中断(解决方案:lockInterruptibly())),这种情况可以通过Lock解决。
- 当多个线程读写文件时,读操作和写操作会发生冲突现象,写操作和写操作也会发生冲突现象,但是读操作和读操作不会发生冲突现象。但是如果采用synchronized关键字实现同步的话,就会导致一个问题,即当多个线程都只是进行读操作时,也只有一个线程在可以进行读操作,其他线程只能等待锁的释放而无法进行读操作。因此,需要一种机制来使得当多个线程都只是进行读操作时,线程之间不会发生冲突(解决方案:ReentrantReadWriteLock)。
- 我们可以通过Lock得知线程有没有成功获取到锁(解决方案:ReentrantLock),但这个是synchronized无法办到的。
Lock锁的简单使用
public class LockUse {
public static void main(String[] args) {
Ticket ticket = new Ticket();
new Thread(ticket).start();
new Thread(ticket).start();
new Thread(ticket).start();
}
}
class Ticket implements Runnable {
private static Integer ticketNums = 10;
/**
* 声明可重入锁
*/
private final ReentrantLock lock = new ReentrantLock();
/**
* 不加锁的情况下,线程不安全,
* 因此可以使用Lock进行显示的加锁和解锁,锁lock必须紧跟try代码块,且unlock要放到finally第一行。
*/
@Override
public void run() {
while (true) {
// 加锁,锁lock必须紧跟try代码块,且unlock要放到finally第一行。
lock.lock();
try {
// lock.lock(); 可以出现在这个位置,但是不建议,
// 因为如果在获取锁时发生了异常,异常抛出的同时也会导致锁无法被释放;
if (ticketNums > 0) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 模拟买票,票自减
System.out.println(ticketNums--);
}
} finally {
// 必须放到第一行
lock.unlock();
}
}
}
}
六、线程通信
6.1 生产者和消费者问题
场景:两个共享固定大小缓冲区的线程——即所谓的“生产者”和“消费者”——在实际运行时会发生的问题。生产者的主要作用是生成一定量的数据放到缓冲区中,然后重复此过程。与此同时,消费者也在缓冲区消耗这些数据。该问题的关键就是要保证生产者不会在缓冲区满时加入数据,消费者也不会在缓冲区中空时消耗数据。
分析:
- 对于生产者,没有生产产品前,要通知消费者等待,生产产品后,通知消费者消费。
- 对于消费者,消费后,通知生产者生产新的产品消费。
Java提供的解决线程通信问题的方法,即:等待/唤醒机制
- wait(): 表示线程一直等待,直到其他线程通知,与sleep不同,会释放锁。
- notify(): 唤醒一个处于等待状态的线程
- notifyAll(): 唤醒同一个对象上所有调用wait()方法的线程,优先级高的线程优先调度
注意:以上方法只能在同步方法或者同步代码块中使用,否则抛出异常,IlleagalMonitorStateException
方式:
- 管道法:采用并发协作模型,加入“缓冲区”,生产者将生产好的数据放入缓冲区,消费者从缓冲区拿出数据。
- 信号灯:在生产者与消费者之间传递信号的一个标志。如当生产者或消费者线程完成自己的工作,等待另一个线程进行时,通过修改信号值来通知对方:我的事情做完了,该你了。另一者获取信号的变化后便会做出对应的行为。在这个过程中,信号值一直被反复更改,直到所有线程均执行完毕。
6.2 管道法
6.2.1 产品、生产者和消费者
@AllArgsConstructor
class Product {
/**
* 产品编号
*/
Integer productId;
}
/**
* 生产者
*/
@AllArgsConstructor
class Production extends Thread {
Buffers buffer;
@Override
public void run() {
for (int i = 0; i < 20; i++) {
try {
buffer.push(new Product(i));
System.out.println("生产了" + i + "个商品");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
/**
* 消费者
*/
@AllArgsConstructor
class Customer extends Thread {
Buffers buffer;
@Override
public void run() {
for (int i = 0; i < 20; i++) {
try {
System.out.println("消费了-->" + buffer.commodity().productId + "个产品");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
6.2.2 缓冲区
缓冲区的大小设定之后,设定一个计数器,生产者和消费者通过计数器去进行产品的生产和消费。生产者生产产品时,首先根据计数器去判断缓冲区是否已经满了,满了的话就等待,然后通知消费者进行消费,如果没有满的话,就继续往里面生产产品。消费者消费的时候,也是通过判断缓冲区中是否有产品存在,如果存在的话就消费,否则等待生产者进行生产,整个生产和消费的过程都是针对缓冲区进行的。
class Buffers {
/**
* 设置容器大小,产品最大数量
*/
Product[] product = new Product[10];
/**
* 计数器
*/
private int count = 0;
/**
* 生产者生产品
*
* @param products
* @throws InterruptedException
*/
public synchronized void push(Product products) throws InterruptedException {
// 如果容器满了,就等待消费者消费
if (count == product.length) {
// 通知消费者消费,生产者等待,wait(),表示线程一直等待,直到其它线程通知,与sleep不同,会释放锁
this.wait();
}
// 如果没满,就丢入产品
product[count] = products;
count++;
// 通知消费者进行消费,notify(),唤醒同一个对象上所有调用wait()方法的线程,优先级高的线程优先调度
this.notifyAll();
}
/**
* 消费者消费产品
*
* @return
* @throws InterruptedException
*/
public synchronized Product commodity() throws InterruptedException {
// 判断是否有产品可以消费
if (count == 0) {
// 消费者等待,等待生产者生产
this.wait();
}
// 消费者进行消费
count--;
Product products = product[count];
// 消费完后,通知生产者生产
this.notifyAll();
return products;
}
}
6.3 信号灯
6.3.1 厨师和顾客
/**
* 生产者:厨师
*/
@AllArgsConstructor
class Cook extends Thread {
Food food;
@Override
public void run() {
for (int i = 0; i < 5; i++) {
try {
food.make("凉皮");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
/**
* 消费者:顾客
*/
@AllArgsConstructor
class Judge extends Thread {
Food food;
@Override
public void run() {
for (int i = 0; i < 5; i++) {
try {
food.eat();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
6.3.2 信号处理
使用信号法时,需要设置一个标志位,通过修改标志位的方式,使得生产者和消费者进行协同工作。当标志位为true时,生产者进行生产,消费者等待,所以在制作食物的方法中,首先让消费者(顾客)进行等待,等待生产者(厨师)进行食物制作,厨师制作完成之后,通知顾客吃饭,同时修改标志位。顾客吃饭的时候,厨师等待,顾客吃完后,通知厨师继续做饭,同时修改标志位,使得两个线程有序的进行协同工作。
class Food {
/**
* 设置标志位,true为厨师烹饪食物,顾客等待,false为厨师等待,顾客吃饭
*/
boolean flag = true;
/**
* 食物
*/
String foodName;
/**
* 烹饪食物
*
* @param foodName 食物
* @throws InterruptedException
*/
public synchronized void make(String foodName) throws InterruptedException {
// 如果flag为false则厨师等待顾客吃饭,生产者厨师等待,消费者顾客进行吃饭
if (!flag) {
this.wait();
}
System.out.println("厨师做了一道" + foodName);
// 唤醒消费者消费
this.notifyAll();
// 将厨师做的菜传递给总的菜类
this.foodName = foodName;
// 让flag为false,则消费者消费
this.flag = !this.flag;
}
/**
* 消费者吃饭
*
* @throws InterruptedException
*/
public synchronized void eat() throws InterruptedException {
// flag为true则顾客等待厨师做饭,消费者等待,生产者生产
if (flag) {
this.wait();
}
System.out.println("顾客吃了" + foodName);
// 唤醒,唤醒生产者(厨师)做菜
this.notifyAll();
// 使flag为true,让生产者继续生产
this.flag = !this.flag;
}
}
七、拓展
7.1 Thread.sleep(0)的作用
Thread.sleep(0)可以让线程进入Safepoint,从而触发GC。

浙公网安备 33010602011771号