不管前台还是后台开发,总避免不了多线程的运用。是时候,好好梳理一下多线程的基本概念了。
线程的概念
进程一般指一个执行单元。
进程在PC和移动端通常指一个程序和一个应用。
在Android中,我们也可以通过在AndroidMenifest中指定四大组件的android:process属性,为应用开启多个进程。
线程是操作系统调度的最小单元,是进程的执行单元。
进程和线程的关系如下。
进程与线程是一对多的关系。一个进程可以拥有一个或多个线程,一个线程只有一个父进程。
线程之间共享父进程的共享资源,相互之间协同完成父进程所要完成的任务。
一个线程可以创建和撤销另一个线程,同一个进程的多个线程之间可以并发执行。
线程的创建
继承Thread类创建线程
1 | public class MyThread extends Thread { |
实际开发中,可以用匿名内部类快速开启一个线程。
1 | new Thread(){ |
实现Runnable接口创建线程
1 | public class MyRunnable implements Runnable { |
同样,我们可以用匿名内部类快速开启一个线程。
1 | new Thread(new Runnable() { |
线程的生命周期
五大状态
当一个线程开启后,它会经过新建,就绪,运行,阻塞和死亡这五种状态。
新建状态:线程刚被创建出来,此时线程并无任何动态特征。
就绪状态:线程可以运行。
运行状态:线程正在运行。
阻塞状态:线程处被暂停。
死亡状态: 线程被停止。
线程优先级
设置和获取线程优先级的方法。
1 | public final void setPriority(int newPriority) |
线程优先级的取值范围为1到10,也可以用静态变量设置。
1 | public static final int MIN_PRIORITY = 1; |
线程默认的优先级是父线程的优先级。Java主线程的默认优先级是NORM_PRIORITY = 5。
后台线程
设置后台线程以及判断线程是否为后台线程的方法。
1 | public final void setDaemon(boolean on) |
start() 和 run()
线程新建完之后,调用start()方法,使线程处于就绪状态。
当就绪状态的线程获得CPU资源后,会自动执行run()方法,使线程处于运行状态。
sleep()
线程在运行状态时,调用sleep()方法,使线程处于阻塞状态。
1 | public static native void sleep(long millis) throws InterruptedException |
这里Java中的native关键字,说明其修饰的方法是一个原生态方法,方法对应的实现不是在当前文件,而是在用其他语言(如C和C++)实现的文件中。Java语言本身不能对操作系统底层进行访问和操作,但是可以通过JNI接口调用其他语言来实现对底层的访问。
接着我们在Activity中加入一个按钮,定义其点击方法sleep()。
1 | public class ThreadActivity extends Activity { |
每隔一秒会输出”Thread Sleep…”,线程生命周期变化如下。
yield()
1 | public static native void yield() |
这个方法通常被翻译为线程让步。顾名思义,当一个线程正在运行的时候,做出了让步,回到就绪状态,重新回到与其他线程再重新分配CPU资源。
1 | public class ThreadActivity extends Activity { |
需要注意的是,这里的打印结果每次都不一定相同。因为线程调用过yield()后,线程调度器可能会启动其他线程,也可能继续启动该线程。
join()
1 | public final void join() throws InterruptedException |
这个方法需要有两个线程。比如在线程A执行体中,线程B调用join()方法。这时候线程A就会被阻塞,必须等线程B执行完或者join()的时间到了,线程A才会继续执行。
1 | public class ThreadActivity extends Activity { |
运行结果如下。可以看出,如果没有设定join的时长,线程A会先让线程B一直执行完毕后,才会继续执行。
1 | 03-31 06:29:45.976 999-1130/com.chen.study E/Chen: 线程A: --> 0 |
多线程常见知识
了解完线程的基本概念,接下来我们需要知道多线程里常见的一些知识。
同步和异步
同步:调用者必须要等到调用的方法返回后才会继续后续的行为。
异步:调用者调用后,不需等调用方法返回就可以继续后续的行为。
通常单线程环境下都是同步行为,我们可以通过开启多条线程实现异步操作。
并发和并行
并发:多个任务交替运行
并行:多个任务同时运行
如果只有一个CPU,系统是不可能并行执行任务,只能并发。因为CPU每次只能执行一条指令。
原子性
原子性:指不可分割的操作。
1 | int a = 1; |
可以看出第一个语句是原子操作,而第二个语句包含读取、加、赋值三个操作,故不是原子操作。
Java内存模型中定义了八种原子操作。
操作 | 作用处 | 说明 |
---|---|---|
lock(锁定) | 主内存变量 | 把一个变量标识成一条线程独占的状态 |
unlock(解锁) | 主内存变量 | 把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定 |
read(读取) | 主内存变量 | 把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用 |
load(载入) | 工作内存变量 | 把 read 操作得到的变量放入到工作内存的变量副本中 |
use(使用) | 工作内存变量 | 将工作内存中的一个变量的值传递给执行引擎 |
assign(赋值) | 工作内存变量 | 将执行引擎接收到的值赋给工作内存的变量 |
store(存储) | 工作内存变量 | 把工作内存中一个变量的值传给主内存中,以便给随后的 write 操作使用 |
write(写入) | 主内存变量 | 把 store 操作从工作内存中得到的变量的值放入主内存变量中 |
在这里补充介绍一下内存模型。
把一个变量从主内存中复制到工作内存中就需要执行read和load操作,将工作内存同步到主内存中就需要执行store和write操作。注意的是,java内存模型只是要求上述两个操作是顺序执行的,而并不是连续执行的。也就是说read和load之间、store和write之间可以插入其他指令。比如对主内存中的a,b进行访问可以出现这样的操作顺序:read a, read b, load b, load a。
可见性
可见性:指一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
在开发过程中,一定要多关注可见性。如果一个线程在修改共享变量,而其他线程并不知道这个修改,就会出现一些意料之外的逻辑错误。
有序性
有序性: 指程序的运行顺序和编写代码的顺序一致。
编译器和处理器会对不具有数据依赖性的操作进行重排序,来提升运行速度。
而数据依赖性只会在单线程中出现。所以,仅观察单一线程,所有操作都是有序的,但多个线程之间的操作,很可能被重排序了。
这个特性也需要我们在处理多线程问题时多加关注。
线程安全
掌握上面的多线程知识,再来理解线程安全就非常容易了。这里我们用经典的多窗口案例来讲。
产生案例
假设一共有10张票,现在一共有两个窗口在卖。这里票就是共享变量,而窗口就是线程。我们可以很容易地写出下面的代码。
1 | public class MainActivity extends AppCompatActivity { |
我们启动两个线程不停循环卖票,每次卖出一张,总票数就减少一张。如果总票数为0,就停止循环。运行结果如下(实际每次运行结果会不一样)。
1 | 03-31 09:51:42.669 19836-20274/com.chen.study D/Chen: 窗口A 卖出第 30 张票,剩余的票数:29 |
可是我们看到,窗口A和窗口B同时都卖出了第24张和第20张票,这并不符合实际情况。而这个产生的原因就是线程不安全。
解决方案
我们可以用synchronized代码块来解决这个线程不安全的问题。
修改上面代码的run()方法,添加synchronized包裹代码块。
1 | public void run() { |
运行结果如下,这次就跟我们预期一样了。
1 | 03-31 10:05:27.282 29787-32670/com.chen.study D/Chen: 窗口A 卖出第 30 张票,剩余的票数:29 |
总结
好了,关于多线程的一些相关概念知识就梳理到这了。相信会对我们在多线程编程时遇到的一些诡异问题的解决有所帮助。