今日内容
- 多线程
教学目标
- 说出进程和线程的概念
- 能够理解并发与并行的区别
- 能够描述Java中多线程运行原理
- 能够使用继承类的方式创建多线程
- 能够使用实现接口的方式创建多线程
- 能够说出实现接口方式的好处
- 能够解释安全问题的出现的原因
第一章 多线程
我们在之前,学习的程序在没有跳转语句的前提下,都是由上至下依次执行,那现在想要设计一个程序,边打游戏边听歌,怎么设计?
要解决上述问题,咱们得使用多进程或者多线程来解决.
1.1 并发与并行
- 并行:指两个或多个事件在同一时刻发生(同时执行)。
- 并发:指两个或多个事件在同一个时间段内发生(交替执行)。
在操作系统中,安装了多个程序,并发指的是在一段时间内宏观上有多个程序同时运行,这在单 CPU 系统中,每一时刻只能有一道程序执行,即微观上这些程序是分时的交替运行,只不过是给人的感觉是同时运行,那是因为分时交替运行的时间是非常短的。
而在多个 CPU 系统中,则这些可以并发执行的程序便可以分配到多个处理器上(CPU),实现多任务并行执行,即利用每个处理器来处理一个可以并发执行的程序,这样多个程序便可以同时执行。目前电脑市场上说的多核 CPU,便是多核处理器,核越多,并行处理的程序越多,能大大的提高电脑运行的效率。
注意:单核处理器的计算机肯定是不能并行的处理多个任务的,只能是多个任务在单个CPU上并发运行。同理,线程也是一样的,从宏观角度上理解线程是并行运行的,但是从微观角度上分析却是串行运行的,即一个线程一个线程的去运行,当系统只有一个CPU时,线程会以某种顺序执行多个线程,我们把这种情况称之为线程调度。
1.2 线程与进程
- 进程:是指一个内存中运行的应用程序,每个进程都有一个独立的内存空间,一个应用程序可以同时运行多个进程;进程也是程序的一次执行过程,是系统运行程序的基本单位;系统运行一个程序即是一个进程从创建、运行到消亡的过程。
- 线程:是进程中的一个执行单元,负责当前进程中程序的执行,一个进程中至少有一个线程。一个进程中是可以有多个线程的,这个应用程序也可以称之为多线程程序。
进程
线程
进程与线程的区别
- 进程:有独立的内存空间,进程是程序的一次执行过程。
- 线程:是进程中的一个执行单元,一个进程中至少有一个线程,一 进程中也可以有多个线程。
**注意:**下面内容为了解知识点
1:因为一个进程中的多个线程是并发运行的,那么从微观角度看也是有先后顺序的,哪个线程执行完全取决于 CPU 的调度,程序员是干涉不了的。而这也就造成的多线程的随机性。
2:Java 程序的进程里面至少包含两个线程,主进程也就是 main()方法线程,另外一个是垃圾回收机制线程。每当使用 java 命令执行一个类时,实际上都会启动一个 JVM,每一个 JVM 实际上就是在操作系统中启动了一个线程,java 本身具备了垃圾的收集机制,所以在 Java 运行时至少会启动两个线程。
3:由于创建一个线程的开销比创建一个进程的开销小的多,那么我们在开发多任务运行的时候,通常考虑创建多线程,而不是创建多进程。
线程调度:
-
分时调度
所有线程轮流使用 CPU 的使用权,平均分配每个线程占用 CPU 的时间。
-
抢占式调度
优先让优先级高的线程使用 CPU,如果线程的优先级相同,那么会随机选择一个(线程随机性),Java使用的为抢占式调度。
1.3 Thread类
线程开启我们需要用到了java.lang.Thread
类,API中该类中定义了有关线程的一些方法,具体如下:
-
构造方法
方法 说明 public Thread() 创建线程对象 public Thread(String name) 创建线程对象并指定线程名字 public Thread(Runnable target) 使用Runnable创建线程 public Thread(Runnable target,String name) 使用Runable创建线程并指定线程名字 -
常用方法
方法 说明 String getName() 获取线程的名字 void start() 开启线程,每个对象只调用一次start void run() run方法写线程执行的代码,此线程要执行的任务在此处定义代码 static void sleep(long millis) 让当前线程睡指定的时间 static Thread currentThread() 获取当前线程对象
翻阅API后得知创建线程的方式总共有两种,一种是继承Thread类方式,一种是实现Runnable接口方式。
1.4实现多线程方式
1.4.1方式一:继承Thread (理解)
一种方法是:将类声明为 Thread 的子类。该子类应重写 Thread 类的 run 方法。接下来可以分配并启动该子类的实例。
在java中,所有的线程对象都必须是Thread类或其子类的实例。每个线程的作用是完成一定的任务,实际上就是执行一段程序流即一段顺序执行的代码。Java使用线程执行体来代表这段程序流。Java中通过继承Thread类来创建并启动多线程的步骤如下:
1)步骤:
A:自定义一个类,继承Thread,这个类称为线程类;
B:重写Thread类中的run方法,run方法中就是线程要执行的任务代码;
C:创建线程类的对象;
D:启动线程,执行任务;
2)代码实现:
需求:演示:实现多线程方式1:继承Thread。
分析和步骤:
1)自定义一个类MyThread ,继承Thread类,在自定义类中重写run函数;
2)在run函数中循环打印1~10数字;
3)定义一个测试类ThreadDemo1,在这个类中创建MyThread类的对象mt;
4)使用对象mt调用start()函数启动线程;
5)在main函数中同样循环输出1~10数字;
/*
需求:演示:实现多线程方式1:继承Thread。
* 实现多线程步骤:
* A:定义一个类继承Thread
* B:复写Thread类中的run函数
* C:创建线程类的对象
* D:启动线程,执行任务
*/
//定义一个类继承Thread,这个类称为线程类
class MyThread extends Thread
{
//B:复写Thread类中的run函数
/*
* 这个函数就是执行线程任务的函数
* 开启线程的目的就是要执行代码,比如360体检、杀毒、清理垃圾等
*/
public void run() {
//这里就是线程要执行的代码
for (int i = 0; i < 10; i++) {
System.out.println(i);
}
}
}
public class ThreadDemo1 {
public static void main(String[] args) {
//C:创建线程类的对象
MyThread mt = new MyThread();
//D:启动线程,执行任务
/*
* 这里我们启动线程之后,由于MyThread类是一个线程,main函数是一个线程,这里有两个线程
* 并且两个线程中都输出0~9,那么根据线程的特点,如果多个线程执行任务,那么CPU执行哪个线程是随机的,
* 并且不固定,所以这里的打印结果0~9应该输出一部分MyThread类中的,然后在输出main函数中的,输出的
* 内容不固定,这样才对
* 但是如果使用线程类对象直接调用run函数,其实这里并没有启动线程,因为启动线程要调用系统资源,开辟内存空间
* run函数中只是执行线程任务的代码,所以这里打印的结果是按顺序打印的,和我们之前调用一般函数并没有区别。
* 那么怎么能够启动线程呢?
* 通过查阅API得知需要调用start()函数才能够启动线程
*/
mt.run();
for (int i = 0; i < 10; i++) {
System.out.println(i);
}
}
}
上述代码输出结果是:
0 1 2 3 4 5 6 7 8 9
0 1 2 3 4 5 6 7 8 9
上述代码有问题,直接使用线程类的对象mt调用run函数这样做是不对的。
这里我们启动线程之后,由于MyThread类是一个线程,main函数是一个线程,这里有两个线程,并且两个线程中都输出0~9,那么根据线程的特点,如果多个线程执行任务,那么CPU执行哪个线程是随机的,并且不固定。
所以这里的打印结果0~9应该输出一部分是MyThread类中的,然后在输出main函数中的,输出的内容不固定,这样才对。
但是如果使用线程类对象直接调用run函数,其实这里并没有启动线程,因为启动线程要调用系统资源,开辟内存空间。而run函数中只是执行线程任务的代码,所以这里打印的结果是按顺序打印的,和我们之前调用一般函数并没有区别。
那么怎么能够启动线程呢?
通过查阅API得知需要调用start()函数才能够启动线程。
启动线程需要使用Thread类中的start()函数。在这个函数的底层调用了系统资源,开辟内存空间,同时调用了该线程的run函数。
也就是说调用了start()函数既启动了线程又调用了run函数。
void start() 导致此线程开始执行; Java虚拟机调用此线程的run方法。
所以修改上述代码变为:
class MyThread extends Thread
{
//B:复写Thread类中的run函数
/*
* 这个函数就是执行线程任务的函数
* 开启线程的目的就是要执行代码,比如360体检、杀毒、清理垃圾等
*/
public void run() {
//这里就是线程要执行的代码
for (int i = 0; i < 10; i++) {
System.out.println(i);
}
}
}
public class ThreadDemo1 {
public static void main(String[] args) {
//C:创建线程类的对象
MyThread mt = new MyThread();
mt.start();
for (int i = 0; i < 10; i++) {
System.out.println(i);
}
}
}
正确的输出结果:
0
1
2
3
0
1
2
3
4
5
6
7
8
9
4
5
6
7
8
9
线程执行代码的特点就是CPU随机执行代码,执行代码顺序不固定。
几个疑问
1)为什么要继承Thread类?
在Java中使用Thread这个类对线程进行描述。而我们现在希望通过自己的代码操作线程,自己的代码应该需要和Thread类之间产生关系。这里我们采用的继承的关系。
当我们继承了Thread类之后,我们自己的类也就变成了线程类。我们自己的类就继承到了Thread类中的所有功能,就具备了操作线程的各种方法,并且自己的类就可以对线程进行各种操作(开启线程等)。
而我们自己定义的类称为了一个线程类,主要是因为可以复写run方法。
2)为什么要复写run方法?
为什么要使用线程:因为我们希望程序中的某段代码可以同时运行,提高程序的运行效率。
我们定义的类继承了Thread类之后,其实在Thread类中有个run方法,它是开启线程之后,就会直接去运行的方法。而Java在设计线程类(Thread)的时候,就已经明确了线程应该执行的某段代码需要书写在run方法中,也就是说在run方法中的代码开启线程之后才能正常的运行。
我们使用线程的目的是让线程执行后来自己程序中的某些代码, 而Java中规定需要线程执行的代码必须写run方法中,Thread类中的run方法中并没有我们真正需要多线程运行的代码,而开启线程又要去运行run方法,这时我们只能沿用Thread类run方法的定义格式,然后复写run方法的方法体代码。
简单来讲:
设计Thread这个API的人,在设计的时候,只设计了如何启动线程,至于线程要执行什么任务,他并不知道。所以,他这样设计:就是start启动线程之后,JVM会自动的调用run方法。
因此,我们只要把自己的代码写到run方法中,就一定会被执行到。
3)为什么要调用start而不是run?
当书写了一个类继承了Thread类之后,这个子类也变成线程类。这时可以创建这个子类的对象,一旦创建Thread的子类对象,就相当于拥有了当前的线程对象。
创建Thread的子类对象,只是在内存中有了线程这个对象,但是线程还不能真正的去运行。
要让线程真正的在内存运行起来,必须调用start方法,因为start方法会先调用系统资源,启动线程。这样才能够在内存开启一片新的内存空间,然后负责当前线程需要执行的任务。
我们直接通过线程对象去调用run方法,这时只是对象调用普通的方法,并没有调用系统资源,启动线程,也没有在内存中开启一个新的独立的内存空间运行任务代码。只有调用start方法才会开启一个独立的新的空间。并在新的空间中自动去运行run方法。
注意:run方法仅仅是封装了线程的任务。它无法启动线程。
面试题:start方法和run方法的区别?
run:只是封装线程任务。
start:先调用系统资源,在内存中开辟一个新的空间启动线程,再执行run方法。
4)同一个线程对象是否可以多次启动线程?
不能。如果同一个对象多次启动线程就会报如下异常。
注意:如果想要启动多个线程,可以重新再创建一次自定义线程类的对象调用一次start()函数来启动线程。
OK 相信我们都看到多线程的现象了,那么接下来几天我们就进入多线程的世界!
获取、设置线程名称
通过以上代码的结果,我们发现只能打印出结果,但是不知道是哪个线程打印的,所以接下来我们需要知道是哪些线程打印的结果,那么我们就得先获取线程的名字,然后才能知道是哪些线程打印的结果。
问题一、那么如何获得线程的名字呢?
要想获得当前线程的名字,首先必须先获得当前线程的对象,然后根据当前线程对象调用线程类Thread类中的方法 String getName()就可以获取当前线程的名称。
问题二、如何获取当前线程的对象?
在Thread类中提供一个函数可以返回当前正在执行的线程的对象,这个函数是:
static Thread currentThread()返回对当前正在执行的线程对象的引用。
说明:这个函数是静态函数,直接通过这个函数所属的类名Thread直接调用即可。
补充:既然有获取线程的名字的函数,那么在Thread类中肯定还会有给线程设置名字的函数,setName(String name)。
void setName(String name) 改变线程名称,使之与参数 name 相同。
需求:演示:获取和设置线程的名称。
分析和步骤:
1)代码和上述代码一致,在run函数中书写getName()函数获取线程的名字;
2)在测试类中分别使用自定义类的线程对象给线程设置新的名字;
代码如下所示:
/*
需求:演示:获取和设置线程的名称。
获取线程的名字使用函数getName()
线程的默认名字是:Thread-x x是从0开始的递增数字
*/
//定义一个类继承Thread,这个类称为线程类
class MyThread2 extends Thread {
// B:复写Thread类中的run函数
public void run() {
// 这里就是线程要执行的代码
/*
* Thread.currentThread().getName();获取当前线程对象的名称
* 由于getName()函数属于Thread类中的函数,而Thread类属于MyThread2的父类
* 所以可以直接通过线程对象调用getName()函数就可以获得线程的名字。
* 哪个线程调用run函数,Thread.currentThread()就会获得哪个线程的对象
*/
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName()+"..."+i);
}
}
}
public class ThreadDemo2 {
public static void main(String[] args) {
// C:创建线程类的对象
MyThread2 mt = new MyThread2();
// D:启动线程,执行任务
mt.start();
// 再创建一个线程对象
MyThread2 mt2 = new MyThread2();
// 再一次开辟一个新的线程
mt2.start();
for (int i = 0; i < 10; i++) {
System.out.println(i);
}
}
}
说明:Thread.currentThread().getName();获取当前线程对象的名称。由于getName()函数属于Thread类中的函数,而Thread类属于MyThread2的父类,所以可以直接通过线程对象调用getName()函数就可以获得线程的名字。哪个线程调用run函数,Thread.currentThread()就会获得哪个线程的对象。
结果如下所示:
Thread-0…0
Thread-1…0
通过以上结果发现多线程是有默认名字的,即Thread-x x是从0开始递增数字。
但是如果我们想给多线程设置我们自己想要的名字,就不会使用默认名字,可以通过Thread类中的setName()进行设置。
代码如下所示:
public class ThreadDemo2 {
public static void main(String[] args) {
// C:创建线程类的对象
MyThread2 mt = new MyThread2();
// 再创建一个线程对象
MyThread2 mt2 = new MyThread2();
//给线程起名字
mt.setName("mt");
mt2.setName("mt2");
// D:启动线程,执行任务
mt.start();
// 再一次开辟一个新的线程
mt2.start();
for (int i = 0; i < 10; i++) {
System.out.println(i);
}
}
}
设置完名字后结果如下所示:
mt…0
mt2…0
多线程程序运行路径图解
说明:这个程序总共开辟了3条执行路径,CPU就会在三者之间来回切换。并发运行,所以这是一个多线程程序。
1.4.2 方式二:实现Runnable接口(掌握)
通过之前的学习我们得知实现多线程的第一种方式是继承Thread类,那么第二种方式是什么呢?
API描述:
创建线程的另一种方法是声明一个实现Runnable接口的类。 那个类然后实现了run方法。 然后可以分配类的实例,在创建Thread时作为参数传递,并启动。
通过以上API得知,第二种方式是创建一个类来实现Runnable接口,并实现Runnable接口中的run函数。
Runnable接口介绍
Runnable接口中只有一个函数如下所示:
void run()当使用实现接口 Runnable的对象来创建线程时,启动线程将使该对象的 run方法在单独执行的线程中被调用。
说明:
1)我们发现,Thread类其实已经实现Runnable接口了,Thread类中的run方法就是实现来自Runnable接口的。
2)Runnable接口中,只有一个run方法,而run方法是用来封装线程任务的。因此,这个接口就是专门用来封装线程任务的接口。为了区分和上述的Thread类,
因此,我们可以这样理解:实现该接口的类,称为任务类。
实现步骤
A:自定义类,实现Runnable接口,这个类就是任务类;
B:实现run方法,run函数中书写的任务代码;
C:创建任务类的对象;
D:创建Thread类对象,并且把任务对象作为参数传递;
说明:既然任务类的对象要作为Thread类构造函数的参数传递,而任务类我们又不知道叫什么名字,但是任务类需要实现Runnable接口,所以在Thread类的构造函数中肯定有Runnable接口类型作为参数接收。
构造方法:
public Thread(Runnable target)
:分配一个带有指定目标新的线程对象。public Thread(Runnable target,String name)
:分配一个带有指定目标新的线程对象并指定名字。
注意:第二个构造函数的String类型的name是给自己定义的任务类线程起名字,不用在使用setName()给线程起名字。
E:启动线程
代码如下所示:
/*
* 演示实现多线程方式2:实现Runnable接口
* A:定义一个类来实现Runnable接口,这个类是任务类
* B:实现Runnable接口中的run函数
* C:创建任务类对象
* D:创建线程类Thread的对象,并把任务类对象作为参数传递
* E:启动线程
*/
//A:定义一个类来实现Runnable接口,这个类是任务类
class MyTask implements Runnable
{
//B:实现Runnable接口中的run函数
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName()+"==="+i);
}
}
}
public class ThreadDemo3 {
public static void main(String[] args) {
//C:创建任务类对象
MyTask task = new MyTask();
// D:创建线程类Thread的对象,并把任务类对象作为参数传递
Thread t1 = new Thread(task,"锁哥");
Thread t2 = new Thread(task,"助教");
//E:启动线程
t1.start();
t2.start();
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName()+"==="+i);
}
}
}
关于调用run方法的疑问
方式1:自定义类继承Thread类,重写run方法,调用start启动线程,会自动调用我们线程类中的run方法。
方式2:自定义类,实现Runnable接口,把任务对象传给Thread对象。调用Thread对象的start方法,执行Thread的run。那么为什么最后执行的是任务类中的run呢?
方式二的好处
A:避免了Java单继承的局限性;
说明:如果使用方式一,那么在Java中一个类只能有一个直接父类,如果一个类已经继承其他的父类,那么当前这个类中假如有需要多线程操作的代码,这时这个类是无法再继承Thread类的。这样就会导致当前这个类中的某些需要多线程执行的任务代码就无法被线程去执行。
B:把线程代码和任务的代码分离,解耦合(解除线程代码和任务的代码模块之间的依赖关系)。代码的扩展性非常好;
说明:Thread类是专门负责描述线程本身的。Thread类可以对线程进行各种各样的操作。如果使用第一种方式,那么把线程要执行的任务也交给了Thread类。这样就会导致操作线程本身的功能和线程要执行的任务功能严重的耦合在一起。
但是方式二,自定义一个类来实现Runnable接口,这样就把任务抽取到Runnable接口中,在这个接口中定义线程需要执行的任务的规则。当需要明确线程的任务时,我们就让这个类实现Runnable接口,只要实现Runnable接口的类,就相当于明确了线程需要执行的任务。
当一个类实现Runnable接口,就相当于有了线程的任务,可是还没有线程本身这个对象。这时我们就可以直接使用Thread这个类创建出线程,然后把任务交给线程。这样就达到任务和线程的分离以及结合。
1.4.3 匿名内部类方式实现线程的创建
使用线程的匿名内部类方式,可以方便的实现每个线程执行不同的线程任务操作。
使用匿名内部类的方式实现Runnable接口,重新复写Runnable接口中的run方法。
public static void main(String[] args) {
//使用匿名内部类实现多线程 r表示任务类的对象
Runnable r=new Runnable(){
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName()+"---"+i);
}
}
};
//创建线程类对象
Thread t1=new Thread(r,"t1");
Thread t2=new Thread(r,"t2");
//启动线程
t1.start();
t2.start();
}
1.5线程控制
线程休眠(掌握)
使用Thread类中的sleep()函数可以让线程休眠,函数如下所示:
static void sleep(long millis) 在指定的毫秒数内让当前正在执行的线程休眠(暂停执行)
说明:这个函数是静态的,使用线程类名调用。使用哪个线程调用就让哪个线程休眠。
代码如下所示:
分析和步骤:
1)创建一个测试类SleepDemo ,并添加main函数;
2)创建一个线程任务类SleepTask 来实现Runnable接口,并实现run函数,打印0到9十个数字,并且使用Thread类调用sleep()函数让当前线程睡一秒,并使用Date类的对象来获得睡眠的时间;
3)在main函数中创建线程任务类对象st;
4)创建线程类对象t,并给线程起名为兔子;
5)启动线程;
/*
* 演示线程的休眠
* public static void sleep(long millis)
*/
//定义一个线程任务类
class SleepTask implements Runnable
{
//实现run函数
public void run() {
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName()+"---"+i+new Date());
//让线程睡一秒
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class SleepDemo {
public static void main(String[] args) {
//创建任务类对象
SleepTask st = new SleepTask();
//创建线程对象
Thread t = new Thread(st,"兔子");
//启动线程
t.start();
}
}
第二章 高并发及线程安全
2.1 高并发及线程安全
- 高并发:是指在某个时间点上,有大量的用户(线程)同时访问同一资源。例如:天猫的双11购物节、12306的在线购票在某个时间点上,都会面临大量用户同时抢购同一件商品/车票的情况。
- 线程安全:在某个时间点上,当大量用户(线程)访问同一资源时,由于多线程运行机制的原因,可能会导致被访问的资源出现安全的问题。
2.2 多线程的运行机制
- 多线程运行机制和之前我们学习的单线程运行机制是不太一样的。当一个线程启动后,JVM会为其分配一个独立的"线程的栈内存区域",这个线程会在这个独立的栈内存区中运行。每启动一个线程就会开辟一个栈内存。每个线程会自己使用自己的栈内存空间
- 多个线程在自己的栈内存空间中会共享同一个堆和方法区。
- 多个线程在各自栈区中独立、无序的运行,当访问一些代码,或者同一个变量时,就可能会产生一些问题
2.3 多线程的安全性问题-可见性
注意:我们接下来要讲解关于多线程的几个安全性问题:可见性、有序性、原子性。要求同学们知道这几个问题是什么原因引起的,如何解决我们后续讲解。我们今天只学习如何发生的,下面讲解的时候只是一种情况,切勿钻牛角尖。
-
例如下面的程序,先启动一个线程,在线程中将一个变量的值更改,而主线程却一直无法获得此变量的新值。代码演示:
public class MyRun implements Runnable { //定义一个静态变量 static int a = 0; @Override public void run() { //睡觉2秒钟 try { Thread.sleep(2000); } catch (InterruptedException e) { } //修改a的值 a = 1; System.out.println("修改a的值为1"); } } public class Demo01 { public static void main(String[] args) { //创建对象 MyRun mr = new MyRun(); Thread t = new Thread(mr); //开启线程 t.start(); while(true){ if(MyRun.a == 1) { System.out.println("获取到了a==1"); } } } }
说明:应该是在前2秒没有任何输出,在两秒之后,a被修改为1,然后就循环输出“获取到了a==1”
执行效果:
但是执行的效果是:“获取到了a==1”没有被输出 -
图解
说明:
1.线程一(主线程):在2秒钟内已经做了无数次获取,每次获取的结果都是0,并且也没有对变量做任何操作
2.线程二:在2秒钟之后虽然已经把a修改为1,但是线程一认为变量已经不会再发生改变了,所以他没有再去方法区中获取新的值,他一直认为这个值是0.
小结:可见性就是多个线程操作同一个变量时,一个线程修改了变量的值,其他线程没有看到,并没有使用修改后的值就是可见性。
2.4 多线程的安全性问题-有序性
在Java中看似顺序的代码在JVM中,可能会出现编译器或者CPU对这些操作指令进行了重新排序;在特定情况下,指令重排将会给我们的程序带来不确定的结果.
指令重排:
在计算机执行指令的顺序在经过程序编译器编译之后形成的指令序列,一般而言,这个指令序列是会输出确定的结果;以确保每一次的执行都有确定的结果。但是,一般情况下,CPU和编译器为了提升程序执行的效率,会按照一定的规则允许进行指令优化,在某些情况下,这种优化会带来一些执行的逻辑问题,主要的原因是代码逻辑之间是存在一定的先后顺序,在并发执行情况下,会按照不同的执行逻辑,会得到不同的结果信息。
-
有些时候“编译器”在编译代码时,会对代码进行“指令重排”,例如:
int a = 10; //1
int b = 20; //2
int c = a + b; //3
第一行和第二行可能会被“指令重排”:可能先编译第二行,再编译第一行,总之在执行第三行之前,会将1,2编译完毕。1和2先编译谁,不影响第三行的结果。
-
但在“多线程”情况下,代码的指令重排,可能会对另一个线程访问的结果产生影响:
说明:
1.上述代码中a、b、c都是成员变量
2.有序性无法使用代码演示,只能看上图分析原理
3.在“多线程”情况下,代码重排,可能会对另一个线程访问的结果产生影响,多线程环境下,我们通常不希望对一些代码进行重排的!!
2.5 多线程的安全性问题-原子性
-
代码演示
//子类实现Runnable接口 public class MyRun2 implements Runnable { static int a = 0; //重写run方法 @Override public void run() { for (int i = 0; i < 10000; i++) { a++; } } } public class Demo02 { public static void main(String[] args) throws InterruptedException { //创建子类对象 MyRun2 mr = new MyRun2(); //创建线程对象 Thread t = new Thread(mr); //开启线程 t.start(); //再开启线程 Thread t2 = new Thread(mr); t2.start(); //为了让循环先执行结束再打印 我们在这里睡两秒钟 Thread.sleep(2000); System.out.println(MyRun2.a); } }
-
内存原理图解:
说明:
1.因为多线程执行共享资源时是抢占式方式执行的,所以会出现问题。
2.线程在操作共享资源时一般会经历如下几个步骤:
1)线程将共享资源(如变量)获取到自己的栈内存中
2)然后修改自己栈内存中的变量的值(如加1操作)
3)操作完毕之后在将数据放到静态区中
3.上述情况产生的原子性过程如下:(只是一种情况的分析)
1.线程二先取出a的值是0放到线程二的栈内存中
2.线程一也取出a的值是0放到线程一的栈内存中
3.线程一修改完a的的值即+1,变为1,将1放到静态区,此时静态区a的值是1
4.线程二也修改完a的的值即+1,变为1,将1放到静态区,此时静态区a的值是1
说明:连续两次+1,a的值最后变为1,因为两个线程都是从0变为1的操作。
原因:两个线程访问同一个变量a的代码不具有"原子性"
第三章 volatile关键字
1.1 什么是volatile关键字
- volatile是一个"变量修饰符",它只能修饰"成员变量",它能强制线程每次从主内存获取值,并能保证此变量不会被编译器优化。
- volatile能解决变量的可见性、有序性。
- volatile不能解决变量的原子性。
1.2 volatile解决可见性
-
将2.3的任务类MyRun做如下修改:
- 线程类:
public class MyRun implements Runnable{ //定义一个静态变量 static volatile int a = 0; @Override public void run() { //睡觉2秒钟 try { Thread.sleep(2000); } catch (InterruptedException e) { } //修改a的值 a = 1; System.out.println("修改a的值为1"); } }
- 测试类
public class Test01 { public static void main(String[] args) throws InterruptedException { //创建对象 MyRun mr = new MyRun(); Thread t = new Thread(mr); //开启线程 t.start(); while(true){ if(MyRun.a == 1) { System.out.println("获取到了a==1"); } } } }
当变量被修饰为volatile时,会迫使线程每次使用此变量,都会去主内存获取,保证其可见性
1.3 volatile解决有序性
代码无法演示。
- 当变量被修饰为volatile时,会禁止代码重排
1.4 volatile不能解决原子性
-
对于示例2.5,加入volatile关键字并不能解决原子性:
- 线程类:
public class MyRun implements Runnable{ static volatile int a = 0; //重写run方法 @Override public void run() { for (int i = 0; i < 10000; i++) { a++; } } }
- 测试类:
public class Test01 { public static void main(String[] args) throws InterruptedException { //创建子类对象 MyRun mr = new MyRun(); //创建线程对象 Thread t = new Thread(mr); //开启线程 t.start(); //再开启线程 Thread t2 = new Thread(mr); t2.start(); //为了让循环先执行结束再打印 我们在这里睡两秒钟 Thread.sleep(2000); System.out.println(MyRun.a); } }
所以,volatile关键字只能解决"变量"的可见性、有序性问题,并不能解决原子性问题,关于原子性问题我们可以使用明天讲解的原子类来解决