首先我们要知道的是进程和线程的概念是在建立在操作系统层面上的,那就来捋捋看怎么就出来了进程和线程。
在单 CPU 时代,若是没有操作系统的控制,那么一个程序会一直在 CPU 上执行,但我们不希望这样,所以就设计出 “时间片” 的概念,这个时间片就是由操作系统的调度器来控制,专门负责切分 CPU 的时间片,轮流分给不同的程序。每一个应用程序只在一个时间片内运行,而时间片过于小,所以在宏观上感觉就是多个程序在同时执行。
在单 CPU 时代,还有一个问题,那就是多个程序会共享同一块内存,这会引起很多麻烦,为了解决这个问题,首先想到的就是为不同的程序分配不同的物理内存块。
然而这样做有个很大的问题:每个程序都要协调商量好怎样使用同一个内存上的不同空间,软件系统和硬件系统千差万别,使这种定制的方案没有可行性。为了解决这个麻烦,计算机系统引入了 “虚拟地址” 的概念,从三方面入手来做:
硬件上,CPU 增加了一个专门的模块叫 MMU,负责转换虚拟地址和物理地址。操作系统上,操作系统增加了另一个核心组件:memory management,即内存管理模块,它管理物理内存、虚拟内存相关的一系列事务。
应用程序上,发明了一个叫做【进程】的模型,(注意)每个进程都用【完全一样的】虚拟地址空间(为什么虚拟地址一样,但是实际的物理地址不一样是因为不同的进程有不同的页表,而线程拥有相同的页表,所以物理地址也相同),然后经由操作系统和硬件 MMU 协作,映射到不同的物理地址空间上。不同的【进程】,都有各自独立的物理内存空间,不用一些特殊手段,是无法访问别的进程的物理内存的。
现在,不同的应用程序,可以不关心底层的物理内存分配,也不关心 CPU 的协调共享了。然而还有一个问题存在:有一些程序,想要共享 CPU,【并且还要共享同样的物理内存】,这时候,一个叫【线程】的模型就出现了,它们被包裹在进程里面,在调度器的管理下共享 CPU,拥有同样的虚拟地址空间,同时也共享同一个物理地址空间,然而,它们无法越过包裹自己的进程,去访问另一个进程的物理地址空间。
还有一些比较基本的定义,进程是程序的一次执行,进程是资源(CPU、内存等)分配的基本单位,它是程序执行时的一个实例。线程是 CPU 的基本调度单位,是进程的一个执行流。
搞清楚了进程和线程的关系,那么多线程就很好说了,指的就是一个进程运行时产生了不止一个线程(一个进程最少有一个线程),在 Java 程序中体现在 main 方法的执行是一个进程,我们在 main 方法中通过 Thread 类来创建多个线程进而运行他们,也就达到多线程的目的。
要想实现多线程,我们要先学会创建线程。创建线程有这么 3 种方式。
1 继承 Thread 类 。声明一个 Thread 的子类,该类需要重写 Thread 类的 run 方法。然后即可通过 start 方法来启动这个线程。
public class Thread1 extends Thread{
private String name;
public Thread1(String name) {
this.name = name;
}
@Override
public void run() {
for(int i = 0;i < 5;i++){
System.out.println(name +"-----"+ i );
try {
sleep(1000); // 不睡也行,睡只是为了演示线程的切换效果。
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
Thread1 t1 = new Thread1("1 号");
Thread1 t2 = new Thread1("2 号");
t1.start();
t2.start();
}
}
2 实现 Runnable 接口。声明一个实现 Runnable 接口的子类,重写 run 方法。
public class TaskNoResult implements Runnable{
private String name;
public TaskNoResult(String name) {
this.name = name;
}
@Override
public void run() {
for(int i = 0;i < 5;i++){
System.out.println(name +"-----"+ i );
try {
Thread.sleep(1000); // 不睡也行,睡只是为了演示线程的切换效果。
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public static void main(String[] args) {
new Thread(new TaskNoResult("任务1")).start();
new Thread(new TaskNoResult("任务2")).start();
}
}
本质上来说,继承 Thread 类也是在实现 Runnable 接口,启动线程的方式只有通过 Thread 类的 start 方法,start 方法是一个 native 方法,通过这个方法去执行 run 方法,run 方法里面的内容只是线程的执行体罢了。记住,启动线程的方式就一种,那就是通过 Thread 类的 start 方法。
实现 Runnable 接口的优点如下:
避免单继承的局限
线程代码可以被多个线程共享
适合多个线程处理同一个资源的情况
使用线程池时,只能放入 Runnable 或 Callable 类型线程。
3 实现 Callable 接口。Callable 接口是在 JDK1.5 中出现的,我们可以通过实现该接口并重写 call 方法来创建线程。
public class TaskWithResult implements Callable<String>{
private int id;
public TaskWithResult(int id) {
this.id = id;
}
@Override
public String call() throws Exception {
return id+"任务被线程驱动执行!";
}
--------------------测试如下-----------------------
public static void main(String[] args) throws InterruptedException, ExecutionException {
// 使用Executors创建一个线程池来执行任务
ExecutorService pool = Executors.newCachedThreadPool();
//Future 相当于是用来存放Executor执行的结果的一种容器
ArrayList<Future<String>> results = new ArrayList<Future<String>>();
for (int i = 0; i < 10; i++) {
results.add(pool.submit(new TaskWithResult(i)));
}
for (Future<String> fs : results) {
if (fs.isDone()) {
System.out.println(fs.get());// 返回任务执行结果
} else {
System.out.println("Future result is not yet complete");
}
}
pool.shutdown();
}
}
Runnable 和 Callable 的区别:
Runnable 重写 run 方法,而 Callable 重写 call 方法。
Runnable 没有返回值, Callable 有返回值。
run 方法不能抛出异常,call 方法可以抛出异常。
运行 Callable 任务可以得到一个 Future 对象。
public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled();
boolean isDone();
// 获得结果,一直等待
V get() throws InterruptedException, ExecutionException;
// 获得结果,等待一定的时间
V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException;
}
Future 是一个接口,他提供给我们方法来检测当前任务是否已经结束,还可以等待任务结束并且拿到一个结果。通过调用 Future 的 get 方法可以当任务结束后返回一个结果值,如果工作没有结束,则会阻塞当前线程,直到任务执行完毕,我们可以通过调用 cancel 方法来停止一个任务,如果任务已经停止,则cancel 方法会返回 true。如果任务已经完成或已经停止或这个任务无法停止,则 cancel 会返回一个 false。当一个任务被成功停止后,他无法再次执行。 isDone 和 isCancel 方法可以判断当前工作是否完成和是否取消。
我们可以这样理解,Runnable 和 Callable 都是用来创建任务,而我们用线程去驱动执行这个任务,常规的做法像这样:
new Thread(new TaskNoResult("任务1")).start();
new Thread(new TaskNoResult("任务2")).start();
但是并不推荐这样使用,推荐使用线程池来创建线程进而驱动任务执行。像这样:
ExecutorService pool = Executors.newCachedThreadPool();
pool.execute(new TaskNoResult("任务1"));
pool.execute(new TaskNoResult("任务2"));
下面奉上一张线程生命周期图,这张图值得好好看看。
简单说一下线程中的几个方法。
start() :启动线程的方法,但是并不保证线程会立即执行。
sleep(long) :暂停线程一段时间,参数为毫秒数。
join() :把指定的线程加入到当前线程执行,等待其执行完毕,可用于控制线程的执行顺序。
yield() :线程让步,只会将 CPU 让给同优先级的线程,但是并不保证一定会让步成功。
最后说一个实际不建议使用的知识点,设置线程的优先级。因为优先级的高低不是决定线程执行顺序的决定因素,所以,千万不要指望设置优先级来控制线程的执行顺序。
t.setPriority(Thread.MIN_PRIORITY); // 最低 1
t.setPriority(Thread.NORM_PRIORITY); // 默认值 5
t.setPriority(Thread.MAX_PRIORITY); //最高 10
下面开始谈谈线程安全问题:
首先明确一个概念,我们说线程安全是默认在多线程环境中,因为单线程中不存在线程安全问题。线程安全体现在多线程环境中程序的执行结果和单线程执行的结果一样。
那么多线程中会存在神马问题呢?举个例子来说,下面这一段代码中存在一个共享变量 count 。当程序调用 add 方法的时候,我们无法确定 count 的值是多少,因为它可能已经被其它线程 add 了很多次,或是正在被 add……
public class Obj {
private int count;
public int add() {
return ++count;
}
}
这种情况是坚决不允许的!除非你就是想计算 add 方法被调用的次数。那我们要保证同一时间只能有一个线程访问共享资源,这也就是在实现线程安全。
如何实现呢?最常见的方式是加锁和同步。
使用锁机制可以保证同一时刻只有一个线程可以拿到锁进入临界区,保证了上锁和释放锁之间的这段代码同一时刻只能被一个线程执行。
public void testLock () {
lock.lock();
try{
int j = i;
i = j + 1;
} finally {
lock.unlock();
}
}
与锁类似的是同步方法或者同步代码块。使用非静态同步方法时,锁住的是当前实例;使用静态同步方法时,锁住的是该类的 Class 对象;使用静态代码块时,锁住的是 synchronized 关键字后面括号内的对象。
public void testLock () {
synchronized (anyObject){
int j = i;
i = j + 1;
}
}
无论使用锁还是 synchronized,本质都是一样,通过锁或同步来实现资源的排它性,从而实际目标代码段同一时间只会被一个线程执行,进而保证了目标代码段的原子性。这是一种以牺牲性能为代价的方法。
除了上面的两种方式还有一起其它的方法,比方说使用关键字 volatile 来修饰共享变量,或是保证每一步操作的原子性,但是在实际开发中不建议使用这些方式来保证线程的安全。
那我们来看一看,保证线程安全到底是在保证什么?底层的逻辑又是什么呢?
线程安全的三大特性,原子性、可见性、有序性。
原子性:类似于数据库中说的原子性,不可分割的操纵。这里说的就是对临界区代码的原子性操作,保证了线程的安全。加锁和同步都是在实现原子性。
可见性:当一个线程修改了变量后立即同步到主存中,让其它线程知道这个变量已经被改变了。就不会读取到过时的数据。
Java 提供了 volatile 关键字来保证可见性。当使用 volatile 修饰某个变量时,它会保证对该变量的修改会立即被更新到内存中,并且将其它线程缓存中对该变量的缓存设置成无效,因此其它线程需要读取该值时必须从主内存中读取,从而得到最新的值。
有序性:在程序的执行顺序和代码的编写顺序一样。这就是有序性,但是Java虚拟机为了优化,有一种重排序的机制,也就是说代码的执行顺序不一定是语句的顺序不一致,这在单线程中不会存在问题,JVM 会保证执行结果的正确。然而在多线程中,就会出现问题。
Java 中可通过 volatile 在一定程序上保证有序性,另外还可以通过 synchronized 和锁来保证有序性。
synchronized 和锁保证有序性的原理和保证原子性一样,都是通过保证同一时间只会有一个线程执行目标代码段来实现的。
JVM 为了优化执行顺序,提高性能。设计了重排序机制,然而这个机制在多线程中可能会带来问题,为了解决这个问题,我们可以通过代码保证有序性。这还不够, JVM 本身存在一个机制 happens-before(先行执行)来保证程序执行顺序。其余的代码就随 JVM 怎么弄了,目的提高性能。
先行执行机制中定义了好几条规则,拿出几条参观一下,你会感觉,呦,原来你认为很自然的事情是被这个规则所定义的!
1 被 volatile 修饰的写操作先发生于后面对该变量的读操作。
2 一个线程内,按照代码顺序执行。
3 Thread 对象的 start() 方法先发生于此线程的其它动作。
4 一个对象构造先于它的 finalize 发生。
……
总结一下,线程安全问题发生在多线程环境下对共享变量的操作中,为了防止出现数据状态不一致的情况,我们可以不在多线程中共享变量、将变量设置为 volatile 、使用同步或锁。其实也是在保证线程的特性不受破坏……