当在写多线程相关的代码时常会出现一些反直觉意想不到的运行结果,主要是因为我们在看到代码望文生义直观地去理解代码的执行过程而对代码背后的实现细节缺乏了解。
接下来根据几个问题详细介绍java多线程的一系列知识点和梳理其脉络。
- 为什么要使用多线程?
- 如何使用多线程?
- 使用多线程可能带来的一些问题?
- 应该如何或通过什么手段去解决这些问题?
一、为什么要使用多线程
理由1--消耗资源少
和进程相比,它是一种非常花销小,切换快,更"节俭"的多任务操作方式。在Linux系统下,启动一个新的进程必须分配给它独立的地址空间,建立众多的数据表来维护它的代码段、堆栈段和数据段,这是一种"昂贵"的多任务工作方式。而在进程中的同时运行多个线程,它们彼此之间使用相同的地址空间,共享大部分数据,启动一个线程所花费的空间远远小于启动一个进程所花费的空间,而且线程间彼此切换所需的时间也远远小于进程间切换所需要的时间。
理由2--方便的通信机制
对不同进程来说,它们具有独立的数据空间,要进行数据的传递只能通过通信的方式进行,这种方式不仅费时,而且很不方便。线程则不然,由于同一进程下的线程之间共享数据空间,所以一个线程的数据可以直接为其它线程所用,这不仅快捷,而且方便。
理由3--效率
除了以上所说的优点外,不和进程比较,多线程程序作为一种多任务、并发的工作方式,当然有以下的优点:
-
提高应用程序响应:
这对图形界面的程序尤其有意义,当一个操作耗时很长时,整个系统都会等待这个操作,此时程序不会响应键盘、鼠标、菜单的操作,而使用多线程技术,将耗时长的操作置于一个新的线程,可以避免这种尴尬的情况。 -
使多CPU系统更加有效:
操作系统会保证当线程数不大于CPU数目时,不同的线程运行于不同的CPU上。 -
改善程序结构:
一个既长又复杂的进程可以考虑分为多个线程,成为几个独立或半独立的运行部分,这样的程序会利于理解和修改。
Windows操作系统,它能同时运行几个程序(独立运行的程序又称之为进程),对于同一个程序,它又可以分成若干个独立的执行流,我们称之为线程,线程提供了多任务处理的能力。用进程和线程的观点来研究软件是当今普遍采用的方法,进程和线程的概念的出现,对提高软件的并行性有着重要的意义。现在的应用软件无一不是多线程多任务处理,单线城的软件是不可想象的。因此掌握多线程多任务设计方法对每个程序员都是必需要掌握的。
二、如何使用多线程?
1. Thread与Runnable
-
方式一
继承Thread类,重写run()方法
public class ThreadDemo1 extends Thread {
public static void main(String[] args) {
// ThreadDemo1继承了Thread类,并重写run()
ThreadDemo1 t = new ThreadDemo1();
// 开启线程:t线程得到CPU执行权后会执行run()中的代码
t.start();
}
@Override
public void run() {
System.out.println("Thread is running");
}
}
-
方式二
实现Runnable接口,实现run()方法
public class ThreadDemo2 implements Runnable{
public static void main(String[] args) {
// ThreadDemo2实现Runnable接口,并实现run()
ThreadDemo2 target = new ThreadDemo2();
// 调用Thread构造方法,传入TreadDemo2的实例对象,创建线程对象
Thread t = new Thread(target);
// 开启线程:t线程得到CPU执行权后会执行run()中的代码
t.start();
}
public void run() {
System.out.println("Thread is running");
}
}
2. Thread与Callable
- 使用Callable+Future
public class Test {
public static void main(String[] args) {
ExecutorService executor = Executors.newCachedThreadPool();
Task task = new Task();
Future<Integer> result = executor.submit(task);
executor.shutdown();
try {
Thread.sleep(1000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
System.out.println("主线程在执行任务");
try {
System.out.println("task运行结果"+result.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
System.out.println("所有任务执行完毕");
}
}
class Task implements Callable<Integer>{
@Override
public Integer call() throws Exception {
System.out.println("子线程在进行计算");
Thread.sleep(3000);
int sum = 0;
for(int i=0;i<100;i++)
sum += i;
return sum;
}
}
执行结果:
子线程在进行计算
主线程在执行任务
task运行结果4950
所有任务执行完毕
- 使用Callable+FutureTask
public class Test {
public static void main(String[] args) {
//第一种方式
ExecutorService executor = Executors.newCachedThreadPool();
Task task = new Task();
FutureTask<Integer> futureTask = new FutureTask<Integer>(task);
executor.submit(futureTask);
executor.shutdown();
//第二种方式,注意这种方式和第一种方式效果是类似的,只不过一个使用的是ExecutorService,一个使用的是Thread
/*Task task = new Task();
FutureTask<Integer> futureTask = new FutureTask<Integer>(task);
Thread thread = new Thread(futureTask);
thread.start();*/
try {
Thread.sleep(1000);
} catch (InterruptedException e1) {
e1.printStackTrace();
}
System.out.println("主线程在执行任务");
try {
System.out.println("task运行结果"+futureTask.get());
} catch (InterruptedException e) {
e.printStackTrace();
} catch (ExecutionException e) {
e.printStackTrace();
}
System.out.println("所有任务执行完毕");
}
}
class Task implements Callable<Integer>{
@Override
public Integer call() throws Exception {
System.out.println("子线程在进行计算");
Thread.sleep(3000);
int sum = 0;
for(int i=0;i<100;i++)
sum += i;
return sum;
}
}
如果为了可取消性而使用 Future 但又不提供可用的结果,则可以声明 Future<?> 形式类型、并返回 null 作为底层任务的结果。
三、多线程可能带来的一些问题?
主要有两个:
- 数据共享带来的线程安全问题
- 死锁问题
1. 数据共享带来的线程安全问题
有多线程的地方就难免有数据共享,而数据的共享就很可能会带来线程安全问题。简单地说一般满足一下两个条件就会引发线程安全问题。
- 多个线程在操作共享的数据
- 操作共享数据的线程代码有多条
-
事例一
以一段代码为例,进行说明
public class ThreadForIncrease {
static int i = 0; //共享数据i
public static void main(String[] args) {
Runnable r = new Runnable() {
@Override
public void run() {//有多条语句操作共享数据
int n = 10000;
while(n>0){
i++;
n--;
}
}
};
//多线程环境
Thread t1 = new Thread(r);
Thread t2 = new Thread(r);
Thread t3 = new Thread(r);
Thread t4 = new Thread(r);
t1.start();
t2.start();
t3.start();
t4.start();
try {
Thread.sleep(10000);//等待足够长的时间 确保上述线程均执行完毕
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(cnt);//输出的结果会小于50000
}
}
出现这种结果,主要是因为上文所提及的条件2 操作共享数据的线程代码有多条,你可能产生疑惑这不是只有i++这一条代码去操作共享数据吗?怎么会变成有多条?其实条件2严格的来讲应该是线程里操作共享数据的指令有多条。如果一次操作对应一条操作系统指令,但是很多操作不能通过一条指令就完成。
其实i++和i = i + 1是等效的:
- 读取i的值
- 加1
- 再写回主存
i++其实就是3个指令操作,如果此3个操作之间执行的线程被“挂起”就很可能会发生线程问题。如下图,假设i的初始值是1,线程1跟线程2各自的3操作交替执行。最后就发生了i明明叫了两次1,结果最后却变成2的“奇怪”现象。
image.png-
事例二
这个问题与单例模式有关,是有编译时指令重排引起的,先看代码
public class Singleton {
private static Singleton instance;
private Singleton() { }
public static Singleton getInstance() {
if (instance == null) {
//判断为null的时候就不去“抢锁”减少资源开销。
//但有可能出现当instance!=null但instance所指向的内存并没有Singleton对象的情况
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
这样一种设计可以保证只产生一个实例,并且只会在初始化的时候加同步锁,看似精妙绝伦,但却会引发另一个问题,这个问题由指令重排序引起。指令重排序是为了优化指令,提高程序运行效率。指令重排序包括编译器重排序和运行时重排序。JVM规范规定,指令重排序可以在不影响单线程程序执行结果前提下进行。例如 instance = new Singleton() 可分解为如下伪代码:
memory = allocate(); //1 分配对象的内存空间
ctorInstance(memory); //2 初始化对象
instance = memory; //3 设置instance指向刚分配的内存地址
但是经过重排序后如下:
memory = allocate(); //1 分配对象的内存空间
instance = memory; //3 设置instance指向刚分配的内存地址,注意,此时对象还没有被初始化!
ctorInstance(memory); //2 初始化对象
将第2步和第3步调换顺序,在单线程情况下不会影响程序执行的结果,但是在多线程情况下就不一样了。线程A执行了instance = memory(这对另一个线程B来说是可见的),此时线程B执行外层 if (instance == null),发现instance不为空,随即返回,但是得到的却是未被完全初始化的实例,在使用的时候必定会有风险,这正是双重检查锁定的问题所在。
2. 死锁问题
此问题暂时不作描述。
四、 应该如何或通过什么手段去解决这些问题?
1. 数据共享带来的线程安全问题
其实简单来说,数据共享带来的线程安全问题可以从以下几个方面寻求解决问题的方案:
- 互斥性
资源互斥是指同时只允许一个访问者对其进行访问,具有唯一性和排它性。通俗地说就是假设某一段代码具有互斥性时在已经有线程在执行该段代码且还没执行完的情况下,其他线程不能执行该段代码。
- 原子性
原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败。同时在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。
PS:数据库有学的话对这个概念应该很清楚
- 可见性
可见性是指当一个线程修改了共享变量后,其他线程能够立即得知这个修改。
- 有序性
为了提高性能,编译器和处理器可能会对指令做重排序。有序就是相对重排后指令顺序而言的,未重排前的指令就是有序的。
事例一解决方案
对于上文代码中出现的问题及其他类似的多线程问题,解决的思路都是从“条件2”入手,将多条操作共享数据的操作合并在一起变为“原子操作”,使这一系列操作不会被线程调度机制打断,这一系列操作一旦开始,就一直运行到结束,中间不会切换到另一个线程。而实现的方法有几种,其中最常用到的就是synchronized关键字。
代码如下:
//使用synchronized实现多线程累加操作
public class synchronizedForIncrease {
static int cnt = 0;
public static void main(String[] args) {
Runnable r = new Runnable() {
@Override
public synchronized void run() {//同步方法(synchronized加锁)
int n = 10000;
while(n>0){
cnt++;
n--;
}
}
};
Thread t1 = new Thread(r);
Thread t2 = new Thread(r);
Thread t3 = new Thread(r);
Thread t4 = new Thread(r);
Thread t5 = new Thread(r);
t1.start();
t2.start();
t3.start();
t4.start();
t5.start();
try {
//等待足够长的时间 确保上述线程均执行完毕
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(cnt);
}
}//输出结果将和预想中的一致:50000
用synchronized修饰run()方法后,就相当于将方法内的多个语句捆绑在一起,要么全部执行,要么尚未开始,不会出现“执行到一半被挂起”的情况也就相当于让run里面的方法具有原子性。同时synchronized也让run()方法具有互斥性,即不同的线程不能同时执行run里面的方法。在这里让run()方法同时具有原子性与互斥性也就避免了线程安全问题的发生。
事例一解决方案
代码:
class Singleton{
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}
这是一种懒汉的单例模式,使用时才创建对象,典型的双重检查锁定(DCL),而且为了避免初始化操作的指令重排序,给instance加上了volatile。
1. 死锁问题
暂时不作描述。
其他java关键字
- ReentrantLock
- AtomicInteger
接下的文章逐一介绍
网友评论