一.基本概念
1).进程
进程的管理
对于操作系统来说,管理进程主要分为两个部分:
1.描述:一个进程可以用一个类(或者结构体)来描述,类中存储了进程的一些特征(优先级,状态,记账信息,上下文等等),我们称之为PCB(进程控制块).
2.管理:用数据结构,将若干个PCB在操作系统内部中整理起来.简单来说,操作系统在内部使用双向链表串联PCB.
进程的虚拟地址空间
在很多进程同时执行的时候,为了防止一个进程非法访问其他进程的内存而导致进程崩溃.我们引入了"虚拟地址空间"的概念.
如图:
这个机制使得每个进程即使访问虚拟地址空间内相同的地址,这个地址在经过MMU处理之后,都会对应真实内存上的一份独一无二的空间.
简单来说,上述机制体现出了进程间的"隔离性".即一个进程运行正常与否,一般不会影响到另一个进程.
注:为了实现进程间的通信,操作系统在隔离性的基础上引入了"进程间通信"的概念.进程间通信的方法很多,但其原理是一致的:引入一个公共媒介,使得进程可以通过访问公共媒介,间接的与其他进程交流.
2).线程
1.引入线程的原因
要想讨论这个问题,首先要先明确进程创建(或销毁)的步骤:
第一步:创建PCB
第二步:分配系统资源(尤其是内存资源)
第三步:将创建好的PCB加入操作系统的双向列表中
以上这三步中,第二步最为耗时.因此在频繁创建和销毁进程的情况下,系统的效率就会受到很大的影响.因此我们引入了"轻量级进程"-----线程.
2.线程的基本概念
1.线程本质上是"执行流",每个线程按照自己的顺序执行自己的代码,多个线程之间"同时"执行自己的代码,彼此之间通常来说不互相影响.
2.之所以将线程称为"轻量级进程",是因为操作系统允许一个进程中包含多个线程(操作系统以PCB为单位),一个进程内的若干线程共享同一份系统资源.这也就意味着,当我们重新创建线程时,不需要重新给其分配资源,只需要复用当前进程内已有的资源即可,从而就省去了上述创建步骤中最耗时的第二步.
3.多线程编程能提高运行效率的前提是系统有足够的CPU资源,当所开线程已经吃满CPU资源后,再增加线程数不仅无法提高程序的运行效率,反而会影响操作系统的调度,降低程序的运行效率.
二.要点整理
1.进程和线程的辨析
1).进程是包含线程的,一个进程内部可以有多个线程.
2).每个进程有自己的虚拟地址空间(内存资源)和文件描述符表(文件资源),进程之间的资源是独立的;而同一个进程的多个线程之间共享上述这些资源.
3).进程是操作系统中资源分配的基本单位;线程是操作系统中调度执行的基本单位.
4).对于多个进程来说,其中一个崩溃一般不会影响其余进程;而同一进程的多个线程中,一个线程崩溃一般会把整个进程一起带走.
2.Thread中run()和start()的辨析
Thread.run()仅会在当前线程中执行Thread线程中的内容,如图:
public class test {
public static void main(String[] args) {
Thread t = new Thread(() -> {
while(true){
System.out.println("该线程在死循环");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.run();
System.out.println("主线程是否能被执行");
}
}
如图代码的执行结果如下:
可见主线程阻塞在了上面的死循环中.
而Thread.start()会在操作系统中创建新的线程,在新的线程中执行Thread线程中的内容,如图:
public class test {
public static void main(String[] args) {
Thread t = new Thread(() -> {
while(true){
System.out.println("该线程在死循环");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.start();
System.out.println("主线程是否能被执行");
}
}
如图代码的执行结果为:
可见主线程的执行并没有受到影响.
3.创建线程
1.继承Thread类,重写run()方法
1).额外实现线程类,继承自Thread
class MyThread extends Thread{
@Override
public void run() {
System.out.println("自实现类继承Thread");
}
}
public class test {
public static void main(String[] args) {
Thread t = new MyThread();
t.start();
}
}
2).使用匿名内部类
public class test {
public static void main(String[] args) {
Thread t = new Thread(){
@Override
public void run() {
System.out.println("使用匿名内部类");
}
};
t.start();
}
}
2.实现runnable接口,重写run()方法
public class test {
public static void main(String[] args) {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("实现runnable接口");
}
};
Thread thread = new Thread(runnable);
thread.start();
}
}
3.使用lambda表达式
public class test {
public static void main(String[] args) {
Thread t = new Thread(() -> {
System.out.println("使用lambda表达式");
});
t.start();
}
}
4.基于Callable与FutureTask创建线程
Callable相比于Runnable,可以传一个返回值,该返回值用FutureTask.get()获取.
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
public class test {
public static void main(String[] args) throws ExecutionException, InterruptedException {
Callable<Integer> callable = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
System.out.println("利用Callable创建线程");
return 0;
}
};
FutureTask<Integer> futureTask = new FutureTask<Integer>(callable);
Thread t = new Thread(futureTask);
t.start();
System.out.println(futureTask.get());
}
}
5.基于线程池创建线程
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class test {
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(10);
for (int i = 0; i < 100; i++) {
executorService.submit(new Runnable() {
@Override
public void run() {
System.out.println("提交任务");
}
});
}
}
}
如图为创建10个线程的线程池.
其中FixedThreadPool是创建固定数量的线程池.java标准库中还有其余两个常用实现,分别为:CachedThreadPool:线程数根据任务动态调整的线程池 和 SingleThreadExecutor:仅单线程执行的线程池.
4.join()方法
join()方法可以控制多个线程之间的结束顺序.若一个线程调用了另一个线程的join()方法,其在代码执行至join()时会阻塞,直到另一个线程执行结束.示例代码如图:
public class test {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
try {
Thread.sleep(2000);
System.out.println("该线程执行结束");
} catch (InterruptedException e) {
e.printStackTrace();
}
});
t.start();
t.join();
System.out.println("主线程内容");
}
}
执行结果如下:
可见主线程在线程t执行完之前,一直阻塞在t.join()部分.
5.守护线程
线程分为前台线程和后台线程.进程结束的前提是所有前台线程执行完,否则就会阻塞等待.而一个线程被创建出来时默认是前台线程(比如main线程).我们可以通过setDaemon()方法将一个前台线程设置成后台线程.(注意:该操作要在start()方法之前调用)代码示例如图:
public class test {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while(true){
System.out.println("死循环");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
t.setDaemon(true);
t.start();
System.out.println("主线程结束");
}
}
运行结果如下:
可见程序会直接因为主线程(唯一的前台线程)结束而结束,而不去考虑后台线程是否结束.
6.线程中断
首先介绍Thread.currentThread()方法,该方法是Thread类中的一个静态方法,可以返回调用该方法的线程的对象.如线程1调用它,就会返回线程1的Thread对象.
线程内部提供了一个标志位(可以简单的理解成一个布尔值),便于程序员操作线程的执行情况,我们可以通过interrupt()方法来修改标志位,通过isInterrupted()方法来获取标志位,标志位为true表示线程应该中断.
其中,interrupt()方法的执行逻辑如下:
1).如果调用interrupt方法时线程正在阻塞,此时interrupt方法会控制线程中阻塞的部分抛出异常,而不修改内置的标志位.
2).如果调用interrupt方法时线程没有阻塞,那么此时interrupt方法就会修改内置的标志位.
示例代码如下:
public class test {
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
while(!Thread.currentThread().isInterrupted()){
try {
Thread.sleep(1000);
System.out.println("线程死循环");
} catch (InterruptedException e) {
System.out.println("线程中断");
break;
}
}
});
t.start();
Thread.sleep(5000);
System.out.println("主线程控制线程退出");
t.interrupt();
}
}
代码的执行结果如下:
可见在主线程调用interrupt()方法时,线程t处于阻塞状态,因此interrupt()方法让阻塞部分抛出异常,该异常被捕获到,从而执行catch部分的内容.
7.线程状态
在java中,线程有如下几种状态:
1).NEW状态.该状态为线程对象被创建出来了但没有调用start方法(即没有在操作系统内部创建出线程).
2).RUNNABLE状态.该状态为线程已经准备好工作了(在就绪队列中但没有被CPU调度)或正常工作(在CPU上执行).
3).TIME_WAITING状态.在RUNNABLE状态下调用sleep()方法即会进入这种状态.为阻塞状态的一种.时间到了之后由操作系统负责唤醒.
4).WATING状态.在RUNNABLE状态下调用wait()方法,即会进入这种状态.为阻塞状态的一种.需要其他线程调用notify()来唤醒.
5).BLOCKER状态.在RUNNABLE状态下,尝试获取已经锁上的锁(synchronize),就会进入这种状态,为阻塞状态的一种.在其他线程释放锁之后,由操作系统负责唤醒.
6).TERMINATED状态.在RUNNABLE状态下,如果线程内的代码全部执行完,就会进入该状态,表示线程已经结束运行.
8.yield()方法
该方法让调用者暂时放弃CPU,重新在就绪队列里排队,其作用相当于sleep(0).
9.线程安全
我们先来展示线程不安全的典型情况--------两个线程同时修改一个变量:
public class test {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count的值为" + count);
}
}
运行结果如下:
以上只是多次运算结果中的一次,结果并不是预期中的10w.
原因分析
产生上述结果的原因是:自增操作并不是原子化的.一个自增操作其实对应着三个机器指令,具体如下:
1).从内存中读取数据到CPU中(变量存储在内存中)-----load.
2).在CPU寄存器中,完成加法运算-----add.
3)把CPU寄存器中的结果写回到内存中-----save.
而操作系统对于线程的调度是随机的,一个线程可能刚执行完add指令,就换成另一个线程执行load指令了.这会导致后来的线程并没有读取到先前线程add之后的值,加法操作的运算结果就出错了.
为了解决这个问题,我们需要把自增操作打个包,使其变成原子化操作.此时我们就引入了synchronize加锁操作.
解决问题
用synchronize将自增操作包起来即可有效解决线程不安全问题.这是因为一个线程通过synchronize对锁对象加锁后,其他线程再想尝试获取锁就会失败,从而阻塞.直到获取到锁的线程执行完save指令将锁释放,这保证了一次加法操作的独立性和完整性.代码如下:
public class test {
public static int count = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (test.class){
for (int i = 0; i < 50000; i++) {
count++;
}
}
});
Thread t2 = new Thread(() -> {
synchronized (test.class){
for (int i = 0; i < 50000; i++) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count的值为" + count);
}
}
我们以当前test类作为类对象,对自增操作加锁.自增操作原子化之后,就可以准确的得到10w了.
其他导致线程不安全的情况
除了多个线程同时修改一个变量之外,还有一些情况也会导致线程不安全问题.
1).内存可见性问题
考虑如下情形:有线程1和线程2.其中线程1一直在循环重复两个操作:从内存中读取某个数据 和 使用该数据进行运算;而线程2每过较长一段时间会修改一次这个数据.
由于从内存中读取数据到CPU所花费的时间远远多于利用该数据进行操作所花费的时间(后者仅在CPU上执行).所以当操作系统发现程序在不断快速(此处一定要注意这个限制)从内存中重复读取一个没有修改的数据时,操作系统会将线程1的操作优化成直接从CPU的寄存器里读.这就会导致线程2对那个数据的修改,对于线程1来说是不可见的,这就导致线程安全问题.代码示例如下:
public class test {
public static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while(!flag){
}
});
Thread t2 = new Thread(() -> {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = true;
});
t1.start();
t2.start();
}
}
预期情况下,上述程序会在5s后结束.然而实际运行时,线程t1始终在死循环中,并没有感知到flag的变化.这就是内存可见性问题.
为此,java引入了volatile关键词.被volatile修饰的关键字,操作系统不会对其进行上述优化.
2).指令重排序问题
考虑如下代码:
Object object = new Object();
这行代码被转化为机器指令后被分成3步:
1.创建内存空间
2.往这个内存空间上构造一个对象
3.将这个内存的引用赋值给object
其中1无疑是最先被执行的,但是2和3的顺序在操作系统的优化下,执行顺序可能会发生变化.这时可能会出现一种情况:其他线程在上述指令以1->3->2的顺序执行时,在第3步之后且第2步之前尝试获取object,此时object实际上是一个无效对象,这就出现了线程安全问题.
总结
线程安全问题的本质是操作系统的随机调度/抢占式执行.
10.wait()与notify()
wait()和notify()主要用于控制多个线程间的运行顺序.
对于调用wait()的线程来说,有如下的执行逻辑:
1).将锁释放并进入阻塞状态
2).等待其他线程调用notify()
3).当通知到达后,就会被唤醒,并重新尝试获取锁.
可见线程调用wait()的前提是得先获取到锁,因此得配合synchronize使用(在synchronize内部).
给出一个示例:
public class test {
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
synchronized (test.class){
for (int i = 0; i < 10; i++) {
if(i == 5){
try {
test.class.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(i);
}
}
});
t1.start();
Thread t2 = new Thread(() -> {
synchronized (test.class){
System.out.println("获取到锁并睡眠");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("唤醒其他线程");
test.class.notify();
}
});
t2.start();
}
}
运行结果:
可见线程t1的运行在调用wait()方法之后中止了.
这里需要注意的是,线程调用sleep()方法并不会将锁释放,而是会将CPU让出,给其他不存在锁竞争关系的线程.
11.单例模式
饿汉模式-----运行即创建
class SingletonHungry{
private static SingletonHungry singletonHungry = new SingletonHungry();
public static SingletonHungry getSingletonHungry() {
return singletonHungry;
}
private SingletonHungry(){}
}
由于饿汉模式下,程序一运行就会创建类对象.所以在多线程获取类对象时,只涉及变量的读取,因此这种情况下不存在线程安全问题.
懒汉模式-----用时再创建
这种情况比较特殊,有且仅有类对象被创建时,才涉及到线程安全问题(即多线程同时读写一个变量).在类对象已经被创建出来之后,懒汉模式退化成饿汉模式.因此我们需要套一层特判,避免因无意义的获取锁而造成程序的性能降低.示例如下:
class SingletonLazy{
private static SingletonLazy singletonLazy = null;
public static SingletonLazy getSingletonLazy() {
if(singletonLazy == null){
synchronized (SingletonLazy.class){
if(singletonLazy == null){
singletonLazy = new SingletonLazy();
}
}
}
return singletonLazy;
}
private SingletonLazy(){}
}
此处第一个if判断的目的是避免除创建对象那次之外,其余情况下的无意义的获取锁的行为;第二个if判断的目的是保证多线程读写下的线程安全.
单例模式的内存分布
12.阻塞队列-----BlockingQueue
阻塞队列相比于原始的Queue,保证了线程安全(即多个线程同时操作一个队列时,不会出现bug).此外,当阻塞队列为空时,尝试出队列会阻塞;当阻塞队列满时,尝试加入元素也会阻塞.
手动实现阻塞队列:
class MyBlockingQueue{
private int[] items = new int[1000];
private int size = 0;
private int head = 0;
private int tail = 0;
public void put(int value) throws InterruptedException {
synchronized (this){
while(size == items.length){
this.wait();
}
items[tail] = value;
tail++;
if(tail == items.length){
tail = 0;
}
size++;
this.notify();
}
}
public int take() throws InterruptedException {
int ret = 0;
synchronized (this){
while(size == 0){
this.wait();
}
ret = items[head++];
if(head == items.length){
head = 0;
}
size--;
this.notify();
}
return ret;
}
}
阻塞队列与普通队列一样,是一个循环队列.使用双向的wait()-notify()机制来保证线程安全.
在代码的put()和take()方法中设置while循环的原因是:在wait()之前,毫无疑问需要判断一次.但是在被唤醒之后,由于操作系统的随机调度,不能保证当前队列的情况就是非空/非满(比如刚put的元素又被其他线程取走了,此时队列仍可能是空),因此我们需要循环判断,保证线程安全.
13.定时器
Java内部有封装好的定时器,这里我们基于多线程自己实现一个,具体如下:
import java.util.concurrent.PriorityBlockingQueue;
class MyTask implements Runnable,Comparable<MyTask>{
private Runnable command;
private long time;
public MyTask(Runnable command,long after){
this.command=command;
this.time=System.currentTimeMillis()+after;
}
public long getTime() {
return time;
}
@Override
public void run() {
this.command.run();
}
@Override
public int compareTo(MyTask o) {
return (int) (this.time-o.time);
}
//实现compareTo接口
}
class MyTimer{
private Object locker = new Object();//锁对象
public PriorityBlockingQueue<MyTask> queue = new PriorityBlockingQueue<>();//优先阻塞队列
public void schedule(Runnable command,long after){
MyTask myTask = new MyTask(command,after);
synchronized (locker){
queue.put(myTask);
locker.notify();
}
}
public MyTimer(){
Thread t1 = new Thread(() -> {
while (true){
synchronized (locker){
try {
MyTask target = queue.take();
if (target.getTime()>(System.currentTimeMillis())){
queue.put(target);
locker.wait(target.getTime()-System.currentTimeMillis());
}else {
target.run();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
});
t1.start();
}
}
一些要点:
1.MyTask类需要实现CompareTo接口,不然无法作为PriorityBlockingQueue的类型参数.
2.wait()操作务必需要和put()操作同在被加锁的代码块内.考虑如下情形:当前时间为10:00,此时从队列中取出任务,发现该任务应在11:00时执行.然后我们应将该任务放回队列中然后wait()1小时.然而,在put()操作和wait()之间,由于线程的随机调度,此时有一个其他线程向队列中添加一个应在10:30执行的任务.
如果wait()操作和put()操作不同在被锁的代码块内,则有可能执行完put(11:00)这个操作后,调度至执行put(10:30)和locker.notify().此时的notify()在wait()之前.这就出现了无效notify().程序仍然会wait()一个小时,10:30这个任务将不会被执行.从而产生线程安全问题.
至于notify()要不要和put()操作同在被锁代码块内,笔者认为不是必须的.只需要保证notify()被加锁即可,因为问题的关键在于notify()与wait()的先后顺序.
14.线程池
前面在线程的创建部分我们已经提及了Java内置的线程池,此处我们利用多线程手动实现一个线程池.代码如下:
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
class MyThreadPool{
private BlockingQueue<Runnable> queue = new LinkedBlockingQueue();
public void submit(Runnable task) throws InterruptedException {
queue.put(task);
}
public MyThreadPool(int n){
for (int i = 0; i < n; i++) {
Thread thread = new Thread(() -> {
while(true){
try {
Runnable runnable = queue.take();
runnable.run();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
thread.start();
}
}
}
public class test4 {
public static void main(String[] args) throws InterruptedException {
MyThreadPool myThreadPool = new MyThreadPool(10);
for (int i = 0; i < 100; i++) {
myThreadPool.submit(new Runnable() {
@Override
public void run() {
System.out.println("线程池"+System.currentTimeMillis());
}
});
}
}
}
一些要点:
1.我们采用阻塞队列来存储提交的任务,使用LinkedBlockingQueue或者ArrayBlockingQueue均可.
2.在线程池内部我们根据参数创建对应数量线程,然后令每个线程都循环从队列中读取任务并执行.