一、多线程基础
1、多线程引入
学习多线程之前我们首先来看一下,下图程序代码的执行顺序
==单线程:如果程序只有一条执行路径,那么该程序就是单线程程序==
==多线程:如果程序有多条执行路径,那么该程序就是多线程程序==
2、什么是多线程
要想了解多线程,必须先了解线程,而要想了解线程,必须先了解进程,因为线程是依赖于进程而存在。
1、什么是进程?
进程就是正在运行的程序,是系统进行资源调度跟分配的独立单位,每一个进程都有自己它自己的内存空间跟系统资源
2、多进程有什么意义
单进程的计算机只能做一件事情,而我们现在使用的计算机,可以同时做多件事情(一边写代码,一边听音-乐)也就是说现在的计算机都是支持多进程的,都可以在一个时间段内执行多个任务
多线程的意义不是所谓的提高程序的执行速度,而是提高cpu的使用率
另外cpu在某个时间点上只能做一件事情,计算机能同时做多个事情是因为cpu轮换调度的太快了而产生的错觉,这也是并行跟并发的区别
3、什么是线程呢?
在一个进程内可以执行多个任务(最少是一个),而这每一个任务我就可以看成是一个线程,线程是进程中的单个顺序控制流,是一条执行路径,是程序使用cpu的最基本的单位
==单线程:如果程序只有一条执行路径,那么该程序就是单线程程序==
==多线程:如果程序有多条执行路径,那么该程序就是多线程程序==
4、多线程有什么意义
多线程的存在不是为了提高程序的执行速度,而是为了提高程序(进程)的使用率
补充:
线程比进程具有更高的性能(在同一个进程之中通信方便),系统创建进程是需要分配资源的,而线程是不需要分配资源,同一个进程中的所有线程共享其父进程的资源,所以创建线程代价小的多,因此使用多线程来实现多任务并发比多进程的效率要高
因为多个线程共享父进程中的全部资源,因此编程更加方便,但是必须更加小心,因为需要确保当前执行的线程不会影响到同一进程中的其他线程
线程是独立运行的他并不知道当前进程中是否还有其他线程存在,线程的执行是抢占式的,也就是说当前正在运行的线程随时都有可能被挂起,以便另外的线程可以执行
线程是操作系统进程中能够并发执行的实体,是处理器调度和分派的基本单位。
在多线程环境中线程是调度和分派的基本单位,而进程是拥有资源的基本单位。
在同一个进程内线程切换不会产生进程切换,由一个进程内的线程切换到另一个进程内的线程时,将会引起进程切换。
3、java中的多线程
1、Java程序的运行原理
java 命令会启动 java 虚拟机,启动 JVM,等于启动了一个应用程序,也就是启动了一个进程。该进程会自动启动一个 “主线程” ,然后主线程去调用某个类的 main 方法。所以 main方法运行在主线程中。在此之前的所有程序都是单线程的。
jvm虚拟机的启动多线程的
2、线程的创建跟启动
我们怎样实现多线程的程序呢?
由于线程是依赖进程而存在的,所以我们应该先创建一个进程出来。而进程是由系统创建的,所以我们应该去调用系统功能创建一个进程。Java是不能直接调用系统功能的,所以,我们没有办法直接实现多线程程序。但是!Java可以去调用C/C++写好的程序来实现多线程程序。由C/C++去调用系统功能创建进程,然后由Java去调用C/C++程序然后封装一些类供我们使用。我们就可以实现多线程程序了。
在java中常见的实现多线程的方式有3种:
- 继承Thread类
- 实现Runnable接口
- 使用Callable跟Fature创建线程
4、实现多线程
java使用Thread类代表线程,所有的线程对象都必须是Thread类或者是其子类的实例,每个线程的作用就是完成一定的任务,实际上就是执行一段程序流(一段顺序执行的代码),java使用线程执行体来代表这个程序流
1、继承Thread类
步骤:
1)、继承Thread类,并重写run方法
2)、创建该类的实例,即创建了线程对象
3)、调用该线程对象的start()方法来启动该线程
public class MyThread extends Thread {
private int i;
@Override
public void run() {
//需要被多线程执行的代码
//一般来说被线程执行的代码一般都是比较耗时的,或者说为了提高用户的体验
for ( i = 0; i < 500; i++) {
System.out.println(i);
}
}
public static void main(String[] args) {
MyThread m1 = new MyThread();
MyThread m2 = new MyThread();
m1.start();
m2.start();
}
}
为什么线程类要重写run()方法呢?
不是类中所有的代码都需要被线程执行,而这个时候为了区分那些代码被线程执行,java就提供了Thread类中的run方法用来包含那些被线程执行的代码(一段顺序执行的程序流)
另外需要注意想要执行多线程的代码不能直接调用我们重写的run
方法而是应该调用线程类的start
方法
run方法跟start方法的区别
run:仅仅是封装了需要被线程执行代码,如果直接调用的话相当于普通方法(单线程)
start:首先是启动了线程,然后再由JVM去调用该线程的run方法
另外同一个线程类不能同时启动俩次否则会产生IllegalThreadStateException
非法的线程状态异常
public static void main(String[] args) {
MyThread m1 = new MyThread();
//IllegalThreadStateException
m1.start();
m1.start();
}
获取线程的名称
- public final String getName():获取线程的名称(在线程类中)
- public static Thread currentThread():返回当前正在执行的线程对象(获取任意方法所在的线程名称)
设置线程名称
- public final void setName(String name):设置线程的名称
- 有参构造器 new MyThread(“600”)
2、实现Runnable接口
步骤:
1)、定义Runnable接口的实现类,并重写该接口的run方法(线程执行体)
2)、创建实现类的实例,并以此实例作为Thread的target来创建Thread对象,该Thread对象才是真正的线 程对象
3)、调用线程对象的start方法来启动线程
public class SecondThread implements Runnable {
private int i;
//run方法同样是线程执行体
@Override
public void run() {
for ( i = 0; i < 100 ; i++) {
//当线程类实现Runnable接口时
//如果想要获取当前线程,只能用Thread.currentThread()方法
System.out.println(Thread.currentThread().getName()+" :"+i);
}
}
public static void main(String[] args) {
//runnable的实现类
SecondThread s1 = new SecondThread();
//通过 new Thread(target,name)方法来创建新线程
Thread th1 = new Thread(s1,"线程1");
Thread th2 = new Thread(s1,"线程2");
th1.start();
th2.start();
}
}
Runnable对象仅仅作为Thread的target,Runnable实现类里包含的run方法仅仅作为线程执行体,而实际的线程对象依然是Thread实例,只是改Thread线程负责执行其target的run方法
Runnable接口中只有一个run方法并且改接口被@FunctionalInterface
修饰说明支持Lambda表达式
3、使用 Callable 和 Future 创建线程
从 JDK5 开始,java提供了 Callable 接口,该接口怎么看都像是 Runnable 接口的增强版,Callable 接口提供了一个 call()
方法可以作为线程执行体,但是该方法比 run()
方法功能更加强大
call()
方法可以有返回值call()
方法可以声明抛出异常
因此完全可以提供一个 Callable 对象作为 Thread 的 target ,而该线程的线程执行体就是该 Callable 对象的 call() 方法。但问题是:Callable接口是 Java 5 新增的一个接口而且它不是Runnable接口的子接口,所以Callable对象不能直接作为 Thread 类的 target 目标执行类。而且 call() 方法还有一个返回值—— call() 方法并不是直接调用,它是作为线程执行体被调用的。那么如何获取call()方法的返回值?
Java 5提供了 Future 接口来代表 Callable 接口里 call() 方法的返回值,并为 Future 接口提供了一个 FutureTask实现类,该实现类实现了 Future 接口,并实现了Runable接口——它可以作为Thread类的target。
在Future接口里定义如下几个公共方法来控制它关联的Callable任务。
boolean cancel(boolean mayInterruptIfRunning)
:试图取消该 Future 里关联的 Callable 任务。V get()
:返回 Callable 任务里 call() 方法的返回值。调用该方法将导致程序阻塞,必须等到子线程结束才会得到返回值。V get(long timeout,TimeUnit unit)
:返回 Callable 任务里 call() 方法的返回值,该方法让程序最多阻塞timeout 和 unit 指定的时间,如果经过指定时间后 Callable 任务依然没有返回值,将会抛出TimeoutException 异常。boolean isCancelled()
:如果在 Callable 任务正常完成前被取消,则返回 true。boolean isDone()
:如果 Callable 任务已经完成,则返回true。
注意: Callable 接口有泛型限制,Callable 接口里的泛型形参类型与 Call() 方法返回值类型相同,而且 Callable 接口是函数式接口,可以使用 Lambda 表达式
创建并启动有返回值的线程的步骤如下:
- 创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,并且该call()方法有返回值。
- 创建Callable实现类的实例,使用FutureTask类来包装Callable对象,该FutureTask对象封装了Callable对象的call()方法的返回值。
- 使用FutureTask对象作为Thread对象的target创建并启动新线程。
- 调用FutureTask对象的get()方法来获得子线程结束后的返回值。
public class ThirdThread {
public static void main(String[] args) throws Exception {
//创建 Callable对象的实现类
Callable<Integer> callable = new Callable<Integer>() {
//线程执行体
@Override
public Integer call() throws Exception {
int i = 0;
for (; i < 100; i++) {
Thread.sleep(200);
System.out.println(Thread.currentThread().getName()
+ " 的循环变量 i 的值: " + i);
}
return i;
}
};
//使用FutureTask类来包装Callable对象,
FutureTask<Integer> task = new FutureTask<Integer>(callable);
for (int i = 0; i < 100; i++) {
Thread.sleep(300);
System.out.println(Thread.currentThread().getName() + " 的循环变量 i 的值: " + i);
if (i == 20) {
//使用FutureTask对象作为Thread对象的target创建并启动新线程。
//实质还是以Callable对象来创建并启动线程的
new Thread(task, "使用callable创建的有返回值的线程").start();
}
}
//调用FutureTask对象的get()方法来获得 子线程结束后的返回值。
System.out.println("线程结束后的返回值:" + task.get());
System.out.println("主线程结束");
}
}
上述程序,当主线程中的循环变量 i == 20
的时候,程序启动以 FutureTask 对象为 target 的线程,程序最后调用 FutureTask 对象的 get 方法来返回 call 方法的返回值 —-> 该方法将导致主线程被阻塞,直到 call( ) 方法结束并返回为止
4、创建线程的三种方式对比
通过继承 Thread 类或实现 Runnable、Callable 接口都可以实现多线程,不过实现 Runnable 接口与实现Callable 接口的方式基本相同,只是 Callable 接口里定义的方法有返回值,可以声明抛出异常而已,因此可以将实现 Runnable 接口和实现 Callable 接口归纳为一种方式。这种方式与继承 Thread 方式之间的主要差别如下:
采用 Runnable、Callable 接口的方式创建多线程的优缺点 :
- 线程类只是实现了 Runnable 接口或 Callable 接口,还可以继承其他类
- 多个线程可以共享一个 target 对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU、代码和数据分开,形成清晰的模型,较好的体现了面向对象的思想。
- 劣势:编程稍微复杂,如果需要访问当前线程,则必须使用 Thread.currentThread() 方法。
采用Thread类的方式创建多线程的优缺点
- 优势: 编写简单,如果需要访问当前线程,直接使用 this 即可。
- 劣势:因为线程类已经继承Thread类,不能再继承其他父类
通常情况下,建议大家采用实现Runnable接口、Callable接口的方式来创建多线程。
5、线程的生命周期(5种)
当线程被创建并启动后,并不是一启动就进入了执行状态,也不是一直处于执行状态,在线程的生命周期中,它要经过`新建(new)`、`就绪(Runnable)`、`运行(Running)`、`阻塞(Blocked)`和`死亡(Dead)`5种状态。尤其是当线程启动以后,它不可能一直占用 CPU 独自运行,所以 CPU 需要在多条线程之间切换,于是线程状态也会多次在运行、阻塞之间切换。
Java采用的是抢占式的调度模型(随机性比较高)
1、新建和就绪状态
新建状态: new 一个线程对象
就绪状态: 创建的线程对象调用了 start()方法
当程序使用 new 关键字创建一个线程之后,该线程就处于新建状态,此时它和其他的Java对象一样仅仅由Java虚拟机为其分配内存,并初始化其他成员变量的值。此时的线程对象没有表现出任何线程的动态特征,程序也不会执行线程的线程执行体。
当线程对象调用了 start 方法之后,该线程就处于就绪状态,Java虚拟机会为这个线程对象创建方法调用栈和程序计数器,处于这个状态中的线程并没有开始运行,只是表示该线程可以运行了。至于什么时候开始运行,则取决于JVM里的线程调度器的调度。
注意:
1、 只能对处于新建状态的线程调用 start 方法,否则会引发 IllegalThreadStateException
2、调用线程对象的 start 方法后,该线程立即进入就绪状态 —–就绪状态相当于 “等待执行” ,该线程并未进 入到运行状态(前面我们的例子,当 i ==20 的时候,子线程并为立即执行)这种是由底层平台控制的具有随机性
3、如果希望调用子线程的 start 方法后,子线程立即执行 可以使用 Thread.sleep(100)方法,让当前线程挂起这个时候 cpu 不会空闲,他就会执行另一个处于就绪状态的线程,这样子线程就能立即执行了
2、运行和阻塞状态
运行:处于就绪状态的线程获得了CPU,开始执行run方法的线程执行体
阻塞:正在运行的线程失去CPU 的执行权
如果处于就绪状态的线程获得了CPU,开始执行run方法的线程执行体,则该线程就处于运行状态,如果计算机只有一个CPU,那么在任何时候只有一个线程处于运行状态。当然,在一个多处理器的机器上,将会有多个线程并行执行,当线程数大于处理器数时,依然会存在多个线程在同一个CPU上轮换的现象。
当一个线程开始运行后,它不可能一直处于运行状态(除非他的线程执行体足够短,瞬间就结束执行),线程在运行过程中需要被中断,目的是使其他线程获得执行的机会,线程调度的细节取决于底层平台所采用的策略。对于采用抢占式策略的系统而言,系统会给每个可执行的线程一个小时间段来处理任务,当时间段用完后,系统就会剥夺该线程所占用的资源,让其他线程获得执行的机会。在选择下一个线程时,系统会考虑线程的优先级。
所有现代的桌面、服务器系统都采用抢占式调度策略,但一些小型设备比如手机则可能采用协作式调度策略,在这样的系统中,只有当一个线程调用了它的 sleep() 或者 yield() 方法后才会放弃所占用的资源,也就是必须由该线程主动放弃所占用的资源。
Thread.sleep() 方法就是将当前线程挂起
线程发生阻塞状态时的情况:
- 线程调用 sleep() 方法主动放弃所占用的处理器资源。
- 线程调用了一个阻塞式 IO 方法,在该方法返回之前,该线程被阻塞。
- 线程试图获得一个同步监视器,但该同步监视器正被其他线程所持有。关于同步监视器的知识,后面会进行讲解。
- 线程在等待某个通知(notify)
- 程序调用了线程的 suspend() 方法将该线程挂起。但这个方法容易导致死锁,所以应该尽量避免使用该方法。
当前正在执行的线程被阻塞后,其他线程就可以获得执行的机会。被阻塞的线程会在合适的时候重新进入就绪状态,注意是就绪状态而不是运行状态,也就是说,被阻塞的线程阻塞解除后,必须重新等待线程调度器的再次调度它。
在发生如下特定情况下,可以解除线程的阻塞状态,让线程重新进入就绪状态
- 调用sleep()方法的线程经过了指定时间。
- 线程调用的阻塞式IO方法已经返回。
- 线程成功地获得了试图取得的同步监视器。
- 线程正在等待某个通知时,其他线程发出了一个通知。
- 处于挂起的线程被调用了 resume() 恢复方法。
线程状态转换图:
从上图中可以看出,线程从阻塞状态只能进入就绪状态,无法直接进入运行状态。而就绪和运行状态之间的转换通常不受程序控制,而是由系统线程调度所决定,当处于就绪状态的线程获得处理器资源时,该线程就进入运行状态,当处于运行状态的线程失去处理器资源时,该线程就进入就绪状态。如果线程调用了 yield() 方法,即线程让步则线程状态进入就绪状态
3、线程死亡
线程会在此3种方法下进行结束,结束后就处于死亡状态。
- run()或call()方法执行完成,线程正常结束
- 线程抛出一个未捕获的Exception或者直接Error错误。
- 直接调用该线程的stop()方法来结束该线程——该方法容易导致死锁,通常不推荐使用。
注意不要对处于死亡状态的线程调用 start 方法 这个前面就说过了,只能对处于新建状态的调用 start方法(只能调用一次)
6、控制线程
Java的线程支持提供了一些便捷的工具方法,通过这些方法可以很好的地控制线程的执行。
1、join 线程
Thread提供了一个让线程等待另一个线程完成的方法——join()方法。当在某个线程执行流中调用其他线程的join()方法时,调用线程将被阻塞,直到被join()方法加入的join线程执行完成为止。
join()方法通常由使用线程的程序调用,以将大问题划分成许多小问题,每个小问题分配一个线程。当所有的小问题都得到处理后,在调用主线程来进一步操作。
让当前线程(主动调用其他开着的线程的 join方法),当其他线程完成后,在运行当前线程
join的内部原理使用的是 wait()方法
public class JoinThread extends Thread {
public JoinThread(String name){
super(name);
}
@Override
public void run() {
for(int i=0;i<40;i++){
System.out.println(this.getName()+" "+i);
}
}
public static void main(String[] args) throws InterruptedException {
//开启子线程
new JoinThread("新线程").start();
for(int i=0;i<40;i++){
if(i==20){
JoinThread jt = new JoinThread("加入的线程");
jt.start();
//开启 jt线程之后,在主线程中调用jt线程的 join 方法
jt.join();
}
System.out.println(Thread.currentThread().getName()+" "+i);
}
}
}
在上面的程序中一共有3个线程,主方法开始时就启动了名为“新线程”的子线程,该子线程将会和main线程并发执行。当主线程的循环变量i等于20的时候,启动了名为“加入的线程”的线程,该线程不会和main线程并发执行,main线程必须等待该线程执行结束后才可以向下执行。在名为“加入的线程”的线程执行时,实际上只有2个子线程在并发执行,即:“新线程”和“加入的线程”。而主线程在处于就绪状态。
2、后台线程
有一种线程在后台运行,它的任务是为其他线程提供服务,这种线程被称为“后台线程”或者“守护线程”。JVM的垃圾回收线程就是典型的后台线程。
后台线程的特征:如果所有的前台线程死亡,后台线程就会自动死亡。
调用Thread对象的setDaemon(true)方法可以将指定线程设置为后台线程。
实例:将线程指定为后台线程,当整个虚拟机中只剩下后台线程时,程序没有运行的必要,虚拟机也随之退出。
public class DaemnThread extends Thread {
@Override
public void run() {
for(int i=0;i<1000;i++){
System.out.println("线程正在运行:"+i);
}
}
public static void main(String[] args) {
DaemnThread dt = new DaemnThread();
dt.setDaemon(true);
dt.start();
for(int i=0;i<20;i++){
System.out.println("当前线程名称:"+Thread.currentThread().getName()
+" "+i);
}
//程序执行到此处,前台线程(主线程)结束
//后台线程也应该随之结束
}
}
查看打印结果,不难发现后台线程无法运行到 999 就结束了
Thread 类还提供了一个 isDaemon()
方法,用来判断是否是后台线程
前台线程死亡后,JVM会通知后台线程死亡,但是从他接收指令到作出相应,需要一定的时间
而且要将某个线程设置为后台线程,必须在线程启动之前也就是说:
setDeamon(true) 必须在 start() 方法之前调用,否则会引发异常
3、线程睡眠 Sleep
如果需要让当前正在执行的线程暂停一段时间,并进入阻塞状态,则可以通过调用 Thread 类的静态 sleep() 方法来实现。sleep()方法有两种重载方式。
- static void sleep(long millis):让当前正在执行的线程暂停millis毫秒,并进入阻塞状态。
- static void sleep(long millis,int nanos):让当前线程暂停millis毫秒加nanos毫微秒,并进入阻塞状态。
与前面类似的是,程序很少调用第二种形式的sleep()方法。
当当前线程调用sleep()方法进入阻塞状态后,在其睡眠时间内,该线程不会获得执行的机会,即使线程中没有其他可以执行的线程,处于 sleep() 中的线程也不会执行,因此 sleep() 方法常用来暂停程序的执行。
会挂起当前正在运行的线程但是不会释放锁
4、线程让步 yield
yield( ) 方法是和 sleep( ) 方法相似的方法,它也是 Thread 类提供的一个静态的方法,同样可以让当前线程暂停,但是不阻塞线程而是让当前线程进入就绪状态。让系统的线程调度器重新调度一次,完全有可能出现的情况是:当某个线程调用了yield()方法暂停之后,线程调度器又将其调度出来重新执行。
实际上,当某个线程调用了yield()方法暂停之后,只有优先级与当前线程相同,或者优先级比当前线程更高的处于就绪状态的线程才会获得执行的机会。
关于sleep()方法和yield()方法的区别:
- sleep()方法暂停当前线程后,会给其他线程执行机会,不会理会其他线程的优先级;但 yield( )方法只会给优先级相同,或优先级更高的线程执行机会。
- sleep()方法将线程转入阻塞状态,直到经过阻塞时间才会转入就绪状态;而yield()不会将线程转入阻塞状态,它只是强制当前线程进入就绪状态。因此完全有可能某个线程调用yield()方法暂停之后,立即再次获得处理器资源被执行。
- sleep()方法声明抛出了InterruptedException,所以调用sleep()方法时要么捕捉该异常,要么显式声明抛出该异常;而yield()方法则没有声明抛出任何异常。
- sleep()方法比yield()方法有更好的可移植性,通常不建议使用yield()方法来控制并发线程的执行。
- 都不释放锁
5、改变线程的优先级
每个线程执行都具有一定的优先级,优先级较高的线程会获得更多的执行机会,而优先级较低的线程则获得较少的执行机会。
每个线程默认的优先级都与创建它的父线程优先级相同,在默认的情况下main线程具有普通优先级,由main线程创建的子线程也具有普通优先级。
Thread类提供了setPriority(int newPriority),getPriority()方法来设置和获取指定线程的优先级,其中setPriority方法的参数可以是一个整数,范围在1~10之间,可以使用Thread类的三个静态常量:MAX_PRIORITY:其值为10,MIN_PRIORITY:其值为1,NORM_PRIORITY:其值为5。
7、线程同步
1、线程安全问题
在多线程编程中,可能会出现多个线程访问一个资源的情况,资源可以是同一内存区(变量,数组,或对象)、系统(数据库,web services等)或文件等等。如果不对这样的访问做控制,就可能出现不可预知的结果。这就是线程安全问题,常见的情况是“丢失更新”、“不可重复读”、“脏读”等等
1、丢失更新
两个事务T1和T2读入同一数据并修改,T2提交的结果破坏了T1提交的结果,导致T1的修改被丢失。
拿火车票订票系统举例:
- 一号窗口读出某班次的火车票余票A,设A=1;
- 二号窗口读出同一班次的火车票余票B,当然也为1;
- 一号窗口判断出余票A=1>0,卖出一张火车票,修改余票A←A-1,A为0,把A写回数据库;
- 二号窗口判断出余票B=1>0,也卖出一张火车票,修改余票B←B-1,B为-1;
余票只有一张,但最后卖出了两张火车票。在程序中,没有对两个窗口对余票的访问做控制,所以造成了这个错误。
例1:火车票订票系统-线程不安全版
public class SellTickets {
public static void main(String[] args) {
TicketsWindow tw = new TicketsWindow();
Thread t1 = new Thread(tw, "一号窗口");
Thread t2 = new Thread(tw, "二号窗口");
t1.start();
t2.start();
}
}
class TicketsWindow implements Runnable {
private int tickets = 1;
@Override
public void run() {
while (true) {
if (tickets > 0) {
System.out.println(Thread.currentThread().getName() +
"还剩余票:" + tickets + "张");
tickets--;
System.out.println(Thread.currentThread().getName() +
"卖出一张火车票,还剩" + tickets + "张");
} else {
System.out.println(Thread.currentThread().getName() +
"余票不足,暂停出售!");
try {
Thread.sleep(1000 * 60 * 5);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
运行结果为
一号窗口还剩余票:1张
二号窗口还剩余票:1张
一号窗口卖出一张火车票,还剩0张
二号窗口卖出一张火车票,还剩-1张
一号窗口余票不足,暂停出售!
二号窗口余票不足,暂停出售!
这明显不是我们想要的结果。
2、脏读
对业务写方法加锁,对业务读方法不加锁 容易产生脏读问题(dirtyRead)
即对写操作进行加锁,但是对读操作没有加锁
public class T6Account {
String name;
double balance;
public synchronized void set(String name, double balance) {
this.name = name;
/*
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
*/
this.balance = balance;
}
public /*synchronized*/ double getBalance() {
return this.balance;
}
public static void main(String[] args) {
T6Account a = new T6Account();
new Thread(() -> a.set("zhangsan", 100.0)).start();
// try {
// TimeUnit.SECONDS.sleep(1);
// } catch (InterruptedException e) {
// e.printStackTrace();
// }
System.out.println(a.getBalance());
// try {
// TimeUnit.SECONDS.sleep(2);
// } catch (InterruptedException e) {
// e.printStackTrace();
// }
System.out.println(a.getBalance());
}
}
2、Synchronized 关键字
为了解决线程安全问题,java 的多线程引入了同步监视器(隐式锁),关键字就是 synchronized
他可以修饰以下几种代码片段:
- 方法。作用范围是整个方法,作用的对象是调用这个方法的对象;
- 代码块。作用范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象;
- 静态方法。作用范围是整个静态方法,作用的对象是这个类的所有对象;
- 类。作用范围是synchronized后面括号括起来的部分,作用的对象是这个类的所有对象。
在任意时刻只能有一个线程能获得同步监视器的锁定,只有该关键字作用范围内的代码执行完毕之后
才会释放同步监视器的锁定
1、修饰方法
public synchronized void method(){
//方法体
}
修改买票系统
@Override
public synchronized void run() {
while (true) {
if (tickets > 0) {
synchronized (this) {
System.out.println(Thread.currentThread().getName()
+ "还剩余票:" + tickets + "张");
tickets--;
System.out.println(Thread.currentThread().getName()
+ "卖出一张火车票,还剩" + tickets + "张");
}
} else {
System.out.println(Thread.currentThread().getName()
+ "余票不足,暂停出售!");
try {
Thread.sleep(1000 * 60 * 5);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
2、修饰静态方法
synchronized还可以修饰静态方法。为什么要把静态方法和方法区分开呢?众所周知,静态方法属于类不属于对象,静态的方法或者属性因为是属于对象本身的,那么它加锁的话就得是 当前类.class
public synchronized static void method() {
//方法体
}
火车票订票系统-synchronized修饰静态方法线程安全版
public class SellTickets {
public static void main(String[] args) {
TicketsWindow tw1 = new TicketsWindow();
TicketsWindow tw2 = new TicketsWindow();
Thread t1 = new Thread(tw1, "一号窗口");
Thread t2 = new Thread(tw2, "二号窗口");
t1.start();
t2.start();
}
}
class TicketsWindow implements Runnable {
private static int tickets = 1;
@Override
public synchronized void run() {
sellTicket();
}
public synchronized static void sellTicket() {
while (true) {
if (tickets > 0) {
System.out.println(Thread.currentThread().getName()
+ "还剩余票:" + tickets + "张");
--tickets;
System.out.println(Thread.currentThread().getName()
+ "卖出一张火车票,还剩" + tickets + "张");
} else {
System.out.println(Thread.currentThread().getName()
+ "余票不足,暂停出售!");
try {
Thread.sleep(1000*60);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
这个例子和前面的例子的最大区别是有两个任务tw1和tw2,但在t1和t2并发执行时却保持了线程同步。这是因为run中调用了静态方法sellTicket,而静态方法是属于类TicketsWindow的,所以tw1和tw2共用了类TicketsWindow的锁。
3、修饰类
class ClassName {
public void method() {
synchronized(ClassName.class) {
//方法体
}
}
}
火车票订票系统-synchronized修饰类线程安全版
public class SellTickets {
public static void main(String[] args) {
TicketsWindow tw1 = new TicketsWindow();
TicketsWindow tw2 = new TicketsWindow();
Thread t1 = new Thread(tw1, "一号窗口");
Thread t2 = new Thread(tw2, "二号窗口");
t1.start();
t2.start();
}
}
class TicketsWindow implements Runnable {
private int tickets = 1;
@Override
public synchronized void run() {
synchronized (SyncThread.class) {
while (true) {
if (tickets > 0) {
System.out.println(Thread.currentThread().getName()
+ "还剩余票:" + tickets + "张");
--tickets;
System.out.println(Thread.currentThread().getName()
+ "卖出一张火车票,还剩" + tickets + "张");
} else {
System.out.println(Thread.currentThread().getName()
+ "余票不足,暂停出售!");
try {
Thread.sleep(1000 * 60);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
}
4、其他
1、sychronized 关键字的作用就是给对某个对象进行加锁(互斥锁)
/**
* synchronized(o){
* ......
* }
*
* 代表的意思是当线程执行到这一句的时候,要向对象 o (堆内存中)申请一把锁
* 否则就不能往下执行
*
* 当有线程给这个对象 o 加上了锁,那么其他想成想要执行的话只能等待,加锁的线程
* 将 synchronized 代码块执行完毕后就会释放锁,这样其他线程就能执行了
*
* synchronized 是互斥锁
*
*/
2、同步跟非同步方法可以同时调用
/**
* 执行m1的时候虽然是给当前对象加了一把锁,但是执行m2的时候并不需要申请锁,
* 所以并不会阻塞但是同样会产生线程不安全的问题
*/
public synchronized void m1() {
System.out.println(Thread.currentThread().getName() + " m1 start...");
try {
a--;
System.out.println("m1线程当前的 a 的值:"+a);
Thread.sleep(2000);
a--;
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " m1 end");
System.out.println("m1线程结束以后 a的值:" + a);
}
public void m2() {
try {
System.out.println("执行m2线程的时候a的值:" + a);
a = a + 10;
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " m2 ");
System.out.println("执行m2线程的时候a的值(+10):" + a);
}
3、synchronized获得的锁是可重入的
/**
* 一个同步方法可以调用另外一个同步方法,一个线程已经拥有某个对象的锁,
* 再次申请的时候仍然会得到该对象的锁.
* 也就是说synchronized获得的锁是可重入的 (获得锁之后,还能再获得一遍)
* 在 m1 的方法中已经对该对象加锁了,然后执行里面的方法,执行m2方法的时候发现
* m2也要申请当前对象的锁,这个时候其实是
* 可以申请成功的,只不过是类似于 +1的操作
*/
public class T7 {
synchronized void m1() {
System.out.println("m1 start");
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
m2();
}
synchronized void m2() {
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("m2");
}
}
4、子类的同步方法调用父类的同步方法可行 这里锁定的是同一个对象
public class T8 {
synchronized void m() {
System.out.println("m start");
System.out.println(this.getClass());
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("m end");
}
public static void main(String[] args) {
TT t = new TT();
t.m();
}
}
class TT extends T8 {
@Override
synchronized void m() {
System.out.println("child m start");
System.out.println(this.getClass());
super.m();
System.out.println("child m end");
}
}
5、出现异常,默认情况锁会被释放
/**
* 程序在执行过程中,如果出现异常,默认情况锁会被释放
* 所以,在并发处理的过程中,有异常要多加小心,不然可能会发生不一致的情况。
* 比如,在一个web app处理过程中,多个servlet线程共同访问同一个资源,这时如果异常处理不合适,
* 在第一个线程中抛出异常,其他线程就会进入同步代码区,有可能会访问到异常产生时的数据。
* 因此要非常小心的处理同步业务逻辑中的异常
*/
public class T9 {
int count = 0;
synchronized void m() {
System.out.println(Thread.currentThread().getName() + " start");
while(true) {
count ++;
System.out.println(Thread.currentThread().getName() + " count = " + count);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
if(count == 5) {
//此处抛出异常,锁将被释放,要想不被释放,可以在这里进行catch,然后让循环继续
// int i = 1/0;
throw new RuntimeException();
}
}
}
public static void main(String[] args) {
T9 t = new T9();
Runnable r = new Runnable() {
@Override
public void run() {
t.m();
}
};
new Thread(r, "t1").start();
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
//如果t1抛出异常之后,不释放锁,t2永远启动不了
new Thread(r, "t2").start();
}
}
5、释放同步监视器的锁定
1、线程会在如下几种情况下释放对同步监视器的锁定(释放锁)
当前线程的同步方法、同步代码块执行结束,当前线程即释放同步监视器。
当前线程的同步代码块、同步方法中遇到break、return终止该代码块、该方法的继续执行,当前线程将会释放同步监视器。
当前线程在同步代码块、同步方法中出现了未处理的Error或者Exception,导致了该代码块、该方法异常结束时,当期线程将会释放同步监视器。
当前线程执行同步代码块或同步方法时,程序执行了同步监视器对象的wait()方法,则当前线程暂停,并释放同步监视器。
2、在如下情况下,线程不会释放同步监视器。
线程执行同步代码块或同步方法时,程序调用了Thread.sleep()、Thread.yield()方法来暂停当前线程的执行,当前线程不会释放同步监视器。
线程执行同步代码块时,其他线程调用了该线程的suspend()方法将该线程挂起,该线程不会释放同步监视器。
3、同步锁(Lock)
从Java5开始,Java提供了一种功能更强大的线程同步机制——通过显示定义同步锁对象来实现同步,在这种机制下,同步锁使用 Lock 对象来充当。
Lock 提供了比 synchronized 方法和 synchronized 代码块更广泛的锁定操作,Lock可以实现更灵活的结构,可以具有差别很大的属性,并且支持多个相关的 Condition 对象。
Lock是控制多个线程对共享资源进行访问的工具。通常,锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开始访问共享资源之前应先获得Lock对象。
某些锁可能允许对共享资源的并发访问,例如 ReadWriteLock 读写锁就允许并发访问。Lock、ReadWriteLock是Java5提供的两个根接口,并为Lock提供了ReentrantLock实现类,为ReadWriteLock提供了ReentrantReadWriteLock实现类。
在实现线程安全的控制中,常用的是ReentrantLock,使用该Lock可以显示的加锁、释放锁
class A {
private final ReentrantLock lock = new ReentrantLock();
public void method() {
//加锁
lock.lock();
try {
//需要保证安全的代码块
} finally {
//释放锁
lock.unlock();
}
}
}
Lock提供了同步方法和同步代码块所没有的其他功能,包括用于非块结构的tryLock()方法,以及试图获取可中断锁的LockInterruptibly()方法,还有获取超时失效锁的tryLock(long time,TimeUnit unit)方法。
4、死锁
当两个线程互相等待对方释放同步监视器时就会发生死锁,Java虚拟机既没有监测,也没有采取措施来处理死锁情况,所以在进行多线程编程时应该采取一些措施来避免死锁现象的出现。一旦出现死锁,整个程序既不会发生任何异常,也不会给出任何提示,只是所有线程处于阻塞状态,无法继续。
8、线程通信
当程序在系统中运行时,线程的调度具有一定的随机性,程序通常无法准确的控制线程的轮换执行,我们可以通过一些机制来保证线程协调运行。线程之间的协调运行就称为通信。
1、传统线程通信
为了实现各个线程直接协调运行,可以借助Object类提供的 wait()
、notify()
和notifyAll()
3个方法,这3个方法必须由同步监视器对象来调用,同步监视器可以分以下两种情况:
- 使用synchronized修饰的同步方法,同步监视器就是当前实例对象,所以此方法由this直接调用
- 使用synchronized修饰的同步代码块,同步监视器是synchronzied后括号里的对象,所以必须使用该对象调用这3个方法。
- 静态的方法或者属性因为是属于对象本身的那么它加锁的话就得是 当前类.class
对上述3个方法的详解:
wait():导致当前线程进入等待,直到其他线程调用该同步监视器的 notify() 方法或者 notifyAll() 方法来唤醒该线程。该wait()方法有三种形式——无时间参数的wait,一直等待,直到其他线程的通知;带毫秒参数的wait和带毫秒、微秒参数的wait,这两种方法都是等待指定时间后自动苏醒。调用 wait方法的当前线程会释放对该同步监视器的锁定。
notify():唤醒此同步监视器上等待的单个线程。如果所有的线程都在此同步监视器上等待着,则会唤醒其中一个线程。选择是任意的。notify()不会释放锁,只有当前线程正常结束释放锁,或者是因为其他原因释放锁之之后,才可以执行被唤醒的线程.,通常跟面跟 wait() 方法连用用来释放锁
notifyAll():唤醒在此同步监视器上等待的所有线程。也不释放锁,只有当前线程放弃对该同步监视器的锁定后,才可以执行被唤醒的线程。
2、使用 Condition 控制线程通信
如果程序不使用synchronized关键字来保证同步,而是直接使用Lock对象来保证同步,则系统中不存在隐式的同步监视器,也就不能使用wait()、notify()、notifyAll()方法进行线程通信了。
当使用Lock对象来保证同步时,Java提供了一个Condition类来保持协调,使用Condition可以让那些已经得到Lock对象却无法继续执行的线程释放Lock对象,Condition对象也可以唤醒其他处于等待的线程。
Condition将同步监视器方法(wait、notify、notifyAll)分解成截然不同的对象,以便通过将这些对象与Lock组合使用,为每个对象提供多个等待集(wait-set)。在这种情况下,Lock替代了同步方法或同步代码块,Condition替代了同步监视器的功能。
Condition实例被绑定在一个Lock对象上。要获得特定Lock对象的Condition实例,调用Lock对象的newCondition()方法即可。Condition类提供了如下三个方法。
await():类似于隐式同步监视器上的wait()方法,导致当前线程等待,直到其他线程调用该Condition的singal()方法或singalAll()方法来唤醒该线程。该await()方法有更多的变体,如long awaitNanos(long nanosTimeout)、void awaitUninterruptibly()、awaitUntil(Date deadline)等,可以完成更丰富的等待操作。
signal():唤醒在此Lock对象上等待的单个线程。如果多有的线程都在该Lock对象上等待,则会唤醒其中一个线程。选择是任意的,也是不释放锁只有当前线程放弃对该Lock对象的锁定后,才可以执行被唤醒的线程。
signalAll():唤醒在此Lock对象上等待的所有线程,只有当前线程放弃对该Lock对象的锁定后,才可以执行被唤醒的线程。
3、使用阻塞队列控制线程通信
Java5提供了一个BlockingQueue接口,虽然BlockingQueue也是Queue的子接口,但它的主要用途并不是作为容器,而是作为线程同步的工具。BlockingQueue具有一个特征:当生产者线程试图向BlockingQueue中放入元素时,如果该队列已满,则线程被阻塞;当消费者线程试图从BlockingQueue中取出元素时,如果该队列已空,则该线程被阻塞。
程序的两个线程通过交替的向BlockingQueue中放入元素,取出元素,即可很好的控制线程通信。
1、BlockingQueue提供如下两个支持阻塞的方法:
put(Element e):尝试把E元素放入BlockingQueue中,如果该队列的元素已满,则放弃更改队列,阻塞线程。
take():尝试从BlockingQueue的头部取出该元素,如果该队列的元素已空,则阻塞该线程。
2、BlockingQueue 继承了Queue接口,当然也可使用Queue接口中的方法
在队列尾部插入元素。包括add、offer、put方法,当队列已满时,这三个方法分别会抛出异常、返回false、阻塞队列。
在队列头部删除并返回该删除的元素。包括remove、poll、take方法,当队列已空时,这三个方法分别会抛出异常、返回false、阻塞队列。
在队列头部取出但不删除元素,包括element、peek方法,当队列已空时,这两个方法分别抛出异常、返回false。
3、BlockingQueue包含如下5个实现类:
ArrayBlockingQueue:基于数组实现的BlockingQueue队列。
LinkedBlockingQueue:基于链表实现的BlockingQueue队列。
PriorityBlockingQueue:它并不是标准的阻塞队列。与前面介绍的PriorityQueue类似,该队列调用了remove、poll、take方法取出元素时,并不是取出队列中存在时间最长的元素,而是队列中最小的元素。
SynchronousQueue:同步队里,该队列的存、取操作必须交替进行。
DelayQueue:它是一个特殊BlockingQueue,底层基于PriorityBlockingQueue实现。不过DelayQueue要求集合元素都实现Delay接口,DelayQueue根据集合元素的getDelay返回值进行排序。