软件构造知识点总结(三)Java多线程与线程安全
文章目录
一、进程与线程
-
进程是资源分配的最小单位,线程是程序执行(CPU调度)的最小单位(资源调度的最小单位);
-
进程有自己的独立地址空间,每启动一个进程,系统就会为它分配地址空间,建立数据表来维护代码段、堆栈段和数据段,这种操作非常昂贵;而线程是共享进程中的数据的,使用相同的地址空间,因此CPU切换一个线程的花费远比进程要小很多,同时创建一个线程的开销也比进程要小很多;
-
线程之间的通信更方便,同一进程下的线程共享全局变量、静态变量等数据,而进程之间的通信需要以通信的方式(IPC)进行。不过如何处理好同步与互斥是编写多线程程序的难点;
打个比方,进程就像是个应用比如说网易云音乐等,而线程就像是在这个进程中间的一次任务,比如你点击切换音乐,查找音乐等。
二、Java多线程的实现
Java中实现多线程的方式有很多,常见的有两种:继承Thread类和实现Runnable接口
1.继承Thread类
与一般类的继承写法基本相同:
class thread extends Thread{
private String name;
public thread(String name) {
this.name=name;
}
@override
public void run() {
System.out.println(Thread.currentThread().getName());
}
}
public static void main(String[] args) {
thread threadA = new thread("A");
threadA.start();
}
要注意的地方:
(1)用来启动线程的是start()
方法,而不是run()
方法;run()
方法称为线程体, 它包含了要执行的这个线程的内容, run()
方法运行结束, 此线程终止。
(2)我们在实现时需要重写run()
方法,因为启动线程时使用的start()方法就是调用了Thread类的run()
方法让线程开始执行,不重写的话,会调用Thread类的run()
方法,不执行任何操作并返回。
(3)一个实例的start()
方法不能重复调用,否则会出现java.lang.IllegalThreadStateException
异常。
2.实现Runnable接口
与一般的接口实现也大致相同,
class thread implements Runnable{
private String name;
public thread(String name) {
this.name=name;
}
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
}
public static void main(String[] args) {
new Thread(new thread("B")).start();
}
需要注意的是:
(1)Runnable只是一个接口,所以必须得重写run()
方法。
(2)Runnable接口里面只有一个run()
方法,没有start()
方法,所以必须得重写run()
方法,并且不能直接启动线程,必须依托其他类,一般通过Thread类来启动Runnable实现多线程。
3.实现Runnable接口的优势
一般来说,如果一个类继承Thread,则不适合资源共享。但是如果实现了Runable接口的话,则很容易的实现资源共享。
实现Runnable接口比继承Thread类所具有的优势:
(1)适合多个相同的程序代码的线程去处理同一个资源
(2)可以避免Java中的单继承的限制
(3)增加程序的健壮性,代码可以被多个线程共享,代码和数据独立
(4)线程池只能放入实现Runable或callable类线程,不能直接放入继承Thread的类
三、线程的状态
-
新建状态(New):
new Thread()
,新创建了一个线程; -
就绪状态(Runnable):新建完成后,主线程(main()方法)调用了该线程的start()方法,CPU目前在执行其他任务或者线程,这个创建好的线程就会进入就绪状态,等待CPU资源运行程序,在运行之前的这段时间处于就绪状态;
-
运行状态(Running):字面意思,线程调用了start()方法之后并且抢占到了CPU资源,运行run方法中的程序代码;
-
阻塞状态(Blocked):阻塞状态时线程在运行过程中因为某些操作暂停运行,放弃CPU使用权,进入就绪状态和其他线程一同进行下次CPU资源的抢占。
当发生如下情况时,线程将会进入阻塞状态:
① 线程调用sleep()方法主动放弃所占用的处理器资源
② 线程调用了一个阻塞式IO方法,在该方法返回之前,该线程被阻塞
③ 线程试图获得一个同步监视器,但该同步监视器正被其他线程所持有。关于同步监视器的知识、后面将有深入的介绍
④ 线程在等待某个通知(notify)
⑤ 程序调用了线程的suspend()方法将该线程挂起。但这个方法容易导致死锁,所以应该尽量避免使用该方法
当前正在执行的线程被阻塞之后,其他线程就可以获得执行的机会。被阻塞的线程会在合适的时候重新进入就绪状态,注意是就绪状态而不是运行状态。也就是说,被阻塞线程的阻塞解除后,必须重新等待线程调度器再次调度它。
解除阻塞:
针对上面几种情况,当发生如下特定的情况时可以解除上面的阻塞,让该线程重新进入就绪状态:
① 调用sleep()方法的线程经过了指定时间。
② 线程调用的阻塞式IO方法已经返回。
③ 线程成功地获得了试图取得的同步监视器。
④ 线程正在等待某个通知时,其他线程发出了个通知。
⑤ 处于挂起状态的线程被调甩了resdme()
恢复方法(会导致死锁,尽量避免使用)。
- 死亡状态(Dead):线程程序执行完成或者因为发生异常跳出了
run()
方法,线程生命周期结束。
一般来说,线程的状态转换如下图所示:
四、线程的调度
1:调整线程优先级:Java线程有优先级,优先级高的线程会获得较多的运行机会。
Java线程的优先级用整数表示,取值范围是1~10,Thread类有以下三个静态常量:
static int MAX_PRIORITY
线程可以具有的最高优先级,取值为10。
static int MIN_PRIORITY
线程可以具有的最低优先级,取值为1。
static int NORM_PRIORITY
分配给线程的默认优先级,取值为5。
Thread类的setPriority()
和getPriority()
方法分别用来设置和获取线程的优先级。
每个线程都有默认的优先级。主线程的默认优先级为Thread.NORM_PRIORITY
。
线程的优先级有继承关系,比如A线程中创建了B线程,那么B将和A具有相同的优先级。
JVM
提供了10个线程优先级,但与常见的操作系统都不能很好的映射。如果希望程序能移植到各个操作系统中,应该仅仅使用Thread
类有以下三个静态常量作为优先级,这样能保证同样的优先级采用了同样的调度方式。
2、线程睡眠:使用Thread.sleep(long millis)
方法,使线程转到阻塞状态。millis
参数设定睡眠的时间,以毫秒为单位。当睡眠结束后,就转为就绪状态。sleep()
平台移植性好。
3、线程等待:使用Object
类中的wait()
方法,导致当前的线程等待,直到其他线程调用此对象的 notify()
方法或notifyAll()
唤醒方法。这个两个唤醒方法也是Object
类中的方法,行为等价于调用 wait(0) 一样。不过应该注意wait()
方法会释放当前的锁,使用的前提是先获得锁。
4、线程让步:使用Thread.yield()
方法,暂停当前正在执行的线程对象,把执行机会让给相同或者更高优先级的线程。但是存在调用以后原线程重新获得执行机会的情况,因为是其自身处于就绪状态,也可以参与资源抢夺。
5、线程加入:使用join()
方法,等待其他线程终止。在当前线程中调用另一个线程的join()
方法,则当前线程转入阻塞状态,直到另一个进程运行结束,当前线程再由阻塞转为就绪状态。
6、线程唤醒:使用Object类中的notify()
、notifyAll()
方法,唤醒一个或者多个正处于等待状态的线程,让他们继续往下执行,直到执行完synchronized 代码块的代码或是中途遇到wait() ,再次释放锁。notify()
方法只唤醒一个等待(对象的)线程并使该线程开始执行。所以如果有多个线程等待一个对象,这个方法只会唤醒其中一个线程,选择哪个线程取决于操作系统对多线程管理的实现。notifyAll
会唤醒所有等待(对象的)线程,尽管哪一个线程将会第一个处理取决于操作系统的实现。
7.线程中断:使用interrupt ()
方法,通过修改了被调用线程的中断状态来告知那个线程, 说它被中断了。对于非阻塞中的线程, 只是改变为中断状态,即Thread.isInterrupted()
将返回true, 但是线程并不会停止运行。对于可取消的阻塞状态中的线程, 比如等待在这些函数上的线程, Thread.sleep(), Object.wait(), Thread.join()
, 这个线程收到中断信号后, 会抛出InterruptedException,线程会提早推出阻塞状态, 同时会把中断状态置回为true。
五、线程安全
1.Java内存模型
了解线程安全的之前先来了解一下 Java 的内存模型,先搞清楚线程是怎么工作的:
Java内存模型JMM是一种基于计算机内存模型(定义了共享内存系统中多线程程序读写操作行为的规范),屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。保证共享内存的原子性、可见性、有序性。规定了何时以及如何做线程工作内存与主内存之间的数据同步。
-
原子性:对共享内存的操作必须是要么全部执行直到执行结束,且中间过程不能被任何外部因素打断,要么就不执行。
-
可见性:多线程操作共享内存时,执行结果能够及时的同步到共享内存,确保其他线程对此结果及时可见。
-
有序性:程序的执行顺序按照代码顺序执行,在单线程环境下,程序的执行都是有序的,但是在多线程环境下,JMM 为了性能优化,编译器和处理器会对指令进行重排,程序的执行会变成无序。
2.线程不安全案例
(1)线程竞争
下图描述了一个多线程执行场景, 线程 A 和线程 B 分别对主内存的变量进行读写操作。其中主内存中的变量为共享变量,也就是说此变量只此一份,多个线程间共享。但是线程不能直接读写主内存的共享变量,每个线程都有自己的工作内存,线程需要读写主内存的共享变量时需要先将该变量拷贝一份副本到自己的工作内存,然后在自己的工作内存中对该变量进行所有操作,线程工作内存对变量副本完成操作之后需要将结果同步至主内存。
主内存中的变量是共享的,所有线程都可以访问读写,而线程工作内存又是线程私有的,线程间不可互相访问。那在多线程场景下,图上的线程 A 和线程 B 同时来操做共享内存里的同一个变量,那么主内存内的此变量数据就会被破坏,也就是说主内存内的此变量不是线程安全的。这就是线程不安全的一个经典案例。
当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竞态条件。导致竞态条件发生的代码区称作临界区,线程交织引起的线程竞争等容易导致程序运行结果出错,所以线程不安全的危害很大。
(2)局部的对象引用
局部变量存储在线程自己的栈中。也就是说,局部变量永远也不会被多个线程共享。所以,基础类型的局部变量是线程安全的。但如果局部变量是一个对象类型呢?对象的局部引用和基础类型的局部变量不太一样。尽管引用本身没有被共享,但引用所指的对象并没有存储在线程的栈内,所有的对象都存在共享堆中,所以对于局部对象的引用,有可能是线程安全的,也有可能是线程不安全的。那么怎样才是线程安全的呢?如果在某个方法中创建的对象不会被其他方法或全局变量获得,或者说方法中创建的对象没有逃出此方法的范围,那么它就是线程安全的。实际上,哪怕将这个对象作为参数传给其它方法,只要别的线程获取不到这个对象,那它仍是线程安全的。
(3)引用
即使一个对象是线程安全的不可变对象,指向这个对象的引用也可能不是线程安全的,也就是说引用可能会被其他线程改变。
3.安全策略
(1)限制数据共享
这种策略很简单,将可变数据限制在单一线程内部,避免竞争,同时不允许任何线程直接读写该数据。归结起来其核心思想就是线程之间不共享可变数据类型
(2)共享不可变数据
这种策略也很简单,因为不可变数据类型通常是线程安全的(我们说通常是因为部分不可变数据类型内部也存在mutaor,我们将其称为beneficent mutation,即有益的改变)。
但是由于引用是不安全的,所以即使一个对象是线程安全的不可变对象,指向这个对象的引用也可能不是线程安全的。也就是说,就算一个类是线程安全的,使用它的类却不能保证是线程安全的,当尝试通过不可变性去获得线程安全时,这点需要注意。
(3)共享线程安全的可变数据
线程安全的数据类型指的是对它们的每一个操作调用,都是原子执行的,不会与其他的操作在指令层面交错执行。对于一般来说,JDK会同时提供两个功能相同的类,一个是线程安全的,另一个不是(一般线程安全的类性能会稍差一些),例如Map
与synchronizedMap
所以如果坚持要使用可变数据类型在多线程之间共享数据,要使用线程安全的数据类型。
但是应该注意的是,使用该策略仍然有线程不安全的风险,因为对于线程安全的数据类型,执行某个操作是线程安全的,但是如果多个操作放在一起,仍然不安全。
(4)同步机制
- 同步锁synchronized
synchronized是Java中的关键字,是一种同步锁。它修饰的对象有以下几种:
- 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象;
- 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
- 修改一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象;
- 修改一个类,其作用的范围是synchronized后面括号括起来的部分,作用主的对象是这个类的所有对象。
在开发中经常看到的用法有:synchronized(this),synchronized(*.class),synchronized(任意对象)
以及在方法/静态方法前加上synchronized
,分别对应上述的几种情况
那么这几种写法又有什么区别呢?
1.synchronized同步方法、synchronized(this)同步代码块
public synchronized void methad()
{
……
}
synchronized(this)
{
……
}
多个线程调用同一个对象中的不同名称的synchronized同步方法或synchronized(this)同步代码块时,是同步的。
(1)synchronized同步方法、synchronized(this)同步代码块
①对其它的synchronized同步方法或synchronized(this)同步代码块调用是堵塞状态;
②同一时间只有一个线程执行synchronized同步方法中的代码。
(2)synchronized(this)同步代码块
①对其它的synchronized同步方法或synchronized(this)同步代码块调用是堵塞状态;
②同一时间只有一个线程执行synchronized同步方法中的代码。
2.将任意对象作为对象监视器
String lock = new String();
synchronized(lock)
{
……
}
多个线程持有对象监视器作为同一个对象的前提下,同一时间只有一个线程可以执行synchronized(任意自定义对象)同步代码快,不过如果与其他的监视器混用,也可以异步执行
3.静态同步synchronized方法、synchronized(*.class)代码块
public synchronized static void method()
{
……
}
synchormized(classA.class)
{
……
}
同步synchronized(*.class)代码块的作用其实和synchronized static方法作用一样。Class锁对类的所有对象实例起作用。
- Lock锁
Lock 是 java.util.concurrent
包下的一个接口,定义了一系列的锁操作方法。Lock 接口主要有 ReentrantLock,ReentrantReadWriteLock.ReadLock,ReentrantReadWriteLock.WriteLock 实现类。与 Synchronized 不同是 Lock 提供了获取锁和释放锁等相关接口,使得使用上更加灵活,同时也可以做更加复杂的操作,如:
Lock lock=new ReentrantLock();
lock.lock();
try
{
}finally
{
lock.unlock();
}
因为Lock是接口所以使用时要结合它的实现类,另外在finall语句块中释放锁的目的是保证获取到锁之后,最终能够被释放,还有最好不要把获取锁的过程写在try语句块中,因为如果在获取锁时发生了异常,异常抛出的同时也会导致锁无法被释放。
当一个线程运行完毕后才把锁释放,其他线程才能执行,但是其他线程的执行顺序也是不确定的。