Java基础之多线程
1.引入多线程的概念
1.1 线程和进程
-
什么是线程,什么又是进程?其实我在刚学计算机的时候对这两个概念总是模模糊糊,感觉就是一个程序。
这里我给出百度百科中线程和进程的定义:
-
线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流
-
进程是一个具有一定独立功能的程序关于某个数据集合的一次运行活动。它是操作系统动态执行的基本单元,在传统的操作系统中,进程既是基本的分配单元,也是基本的执行单元。
-
-
我对进程和线程的基本认知是:进程是一个运行中的程序实体,而线程是这个程序的某一个功能
1.2 单线程和多线程
-
单线程是指程序在执行过程中只有一条运行路径,一次只能执行一个任务,就像一条产品流水线一样,只有当前产品加工完成,下一个产品才会被放到流水线上去加工。
-
多线程指的是在同一个程序或进程中同时执行多个线程。可以理解为把一个产品拆分为多个小的零件,使用多条流水线同时加工不同的零件,这样就提高了产品加工的效率。
1.3 并发和并行
-
并发:是指在同一时间间隔,多个任务交替执行,在宏观上看起来这些任务可能是在同时执行,但是在微观上CPU只是快速的切换了要操作的任务。
-
并行:在同一时刻,多核CPU会同时执行多个任务。
2. Java中多线程的实现方式
2.1 继承Thread类
-
创建一个类继承Thread类,重写run()方法,在这个run()方法里我们就可以定义我们需要并行运行的代码。然后创建这个类的实例对象,调用其start()方法就可以启动线程。
public class ThreadFirst extends Thread{ /** * 重写run方法,线程执行体 */ @Override public void run() { for (int i = 0; i < 20; i++) { System.out.println( getName()+":线程开始执行"); } } }
-
创建测试类,创建两个实例对象并且给这两个线程命名为了打印时方便区分线程。最后调用start()方法启动线程。
public class TestOne { public static void main(String[] args) { // 创建线程对象 ThreadFirst t1 = new ThreadFirst(); ThreadFirst t2 = new ThreadFirst(); // 设置线程名称 t1.setName("线程1"); t2.setName("线程2"); // 启动线程 t1.start(); t2.start(); } }
-
我们这时在控制台查看一下程序的执行结果
-
这样一个简单的继承Thread类的线程就创建完成了。注意:我们启动线程是使用的**start()**方法而不是run()方法,这点要注意区分。
2.2 实现Runnable接口
-
首先创建一个类实现Runnable接口,重写run()方法。
public class ThreadSecond implements Runnable{ /** * 线程执行体 */ @Override public void run() { for (int i = 0; i < 20; i++) { //这里可以使用Tread的静态方法currentThread()获取当前线程对象,然后调用getName()方法获取线程名 System.out.println(Thread.currentThread().getName()+"线程正在执行..."); } } }
-
创建测试类,在测试类中对我们创建的线程类进行实例化,创建Thread线程对象,将我们要执行线程类对象传入Thread的构造方法,为线程命名之后使用start()方法启动线程。
public class TestSecond { public static void main(String[] args) { //表示要执行的线程类 ThreadSecond t = new ThreadSecond(); //传入线程类 使用Thread类来创建线程 Thread t1 = new Thread(t); Thread t2 = new Thread(t); //设置线程名称 t1.setName("线程1"); t2.setName("线程2"); //启动线程 t1.start(); t2.start(); } }
-
运行程序,可以发现同样运行了多条线程。
2.3 实现Callable接口使用FutureTask包装器
-
这种方法创建线程不同于前两种,前两种线程创建方法在对应的线程体中是没有返回值的,因为run()方法的返回值是void。如果我们想获取线程的执行结果该怎么办呢?那么我们就可以使用第三种方法实现Callable接口,重写其中的call()方法,并且将返回的结果交给FutureTask管理,并且将FutureTask的对象传递给我们所创建出来的Thread线程对象,调用start()方法启动线程,再调用FutureTask对象的get()方法就可以拿到线程体运行的返回值。
-
创建一个类实现Callable接口其中泛型<>类型为我们要返回给FutureTask的结果类型。重写call()方法,这个方法就和我们上面重写的run()方法一个性质,都是线程要执行的任务。
public class CallableOne implements Callable<Integer> { /** * 线程执行的方法 * @return Integer * @throws Exception */ @Override public Integer call() throws Exception { int sum = 0; for (int i = 0; i < 100; i++) { sum += i; } return sum; } }
-
创建测试类,在测试类中分别创建线程任务对象、FutureTask对象、线程Thread对象,并且层层递进,将线程任务对象传入FutureTask,再将FutureTask对象传入线程对象,调用start()方法,最终使用FutureTask对象的get()方法获取线程执行结果。
public class TestThree { public static void main(String[] args) throws ExecutionException, InterruptedException { //创建要执行的任务对象 CallableOne callableOne = new CallableOne(); //创建FutureTask对象 这个对象可以获取线程执行结果 FutureTask<Integer> futureTask = new FutureTask<>(callableOne); //创建线程对象 并且将FutureTask对象作为参数传递 Thread t1 = new Thread(futureTask); //启动线程 t1.start(); //获取线程执行结果 int i = futureTask.get(); System.out.println(i); } }
3.线程调度和线程优先级
3.1 线程调度方式
- Java虚拟机所采用的线程调度方式是抢占式调度。特点是虚拟机根据一定的策略(比如为线程设置优先级,优先级高的线程有更大的机会获得CPU时间,优先级并不保证执行顺序,但会影响调度的选择倾向。)决定线程的执行顺序,目的是实现对资源的有效管理和高效运行。
3.2 线程优先级
- 线程的优先级只是表示一个线程优先执行程度的指标,它可以线程调度器安排线程的执行顺序,但是并不能保证优先级高的线程比优先级低的线程先执行。Java中是通过Thread类中的setPriority()方法来设置优先级。范围是从1到10,默认的线程优先级是5。
3.3 用户线程和守护线程
- 用户线程:用户线程是最常见的线程类型,默认情况下,新创建的线程都是用户线程。Java虚拟机会等待所有用户线程执行完毕后才会终止运行。
- 守护线程:守护线程服务于用户线程,比如说垃圾回收线程就是一个守护线程。当所有非守护线程结束时,Java虚拟机会自动终止守护线程,即使它们还在执行任务。
- 这两种线程的主要区别是Java虚拟机何时终止运行:只有当所有用户线程都执行完毕后,JVM才会退出,而不管守护线程是否还在运行。通过调用
Thread.setDaemon(true)
方法可以在线程启动之前将其设置为守护线程。
4. 线程的生命周期
- 什么是线程的生命周期?线程的生命周期指的是线程从创建到终止的过程,一般有五个状态。
- 新建:当使用new关键字创建了一个Thread对象时,线程属于新建状态。此时线程尚未被系统调度,仅仅是一个实例对象。
- 就绪:当调用线程对象的start()方法后,线程就进入了就绪状态,这时线程已经准备就绪,等待CPU调度执行,并不意味着它正在被执行。
- 运行:当CPU为其分配了时间片后,线程运行run()方法,这时线程才正在被执行。
- 阻塞:当线程执行过程中遇到了阻塞事件,线程就会进入阻塞状态,暂停执行,直到阻塞事件解除。
- 死亡:run()方法执行结束或者程序出现异常,线程进入死亡状态,生命周期结束。
如图:
5. 线程同步
5.1 线程安全
-
介绍线程同步之前我们首先引入一个概念:线程安全,线程安全是指在多线程环境下,程序能正确地执行并得到预期的结果,而不会因为线程的调度顺序或交替执行引起数据不一致、数据损坏等问题。
-
举个栗子:创建一个线程类用来表示售卖100份商品,再创建3个线程对象表示三个售货员
public class Sale extends Thread{ // 定义一个变量,用来记录销售的数量 static int count = 0; @Override public void run() { while (true){ if (count<100){ // 销售数量加1 try { sleep(10); } catch (InterruptedException e) { throw new RuntimeException(e); } count++; System.out.println(Thread.currentThread().getName() + " 销售了第 " + count + " 个商品"); }else { break; } } } } public class Test { public static void main(String[] args) { // 创建三个线程 Sale t1 = new Sale(); Sale t2 = new Sale(); Sale t3 = new Sale(); t1.setName("张三"); t2.setName("李四"); t3.setName("王五"); // 启动三个线程 t1.start(); t2.start(); t3.start(); } }
-
当我们运行主方法查看控制台的时候会发现多个线程售卖了同一份商品,甚至售卖的数量超过了我们所预期的售卖数量。
- 这就是就引入了线程安全的问题,在多线程的情况下,不同线程会抢夺cpu的时间片,所以导致了要执行的代码块中在一个线程没执行完前,其他线程获得了执行权并且开始执行,这就导致了数据不一致的问题。
5.2 同步代码块
-
为了解决上述问题,我们引入synchronized关键字来实现同步代码块,目的是同步代码块中的代码在每次被一个线程执行的时候,这个线程此时就会获得同步监视器的所有权(也就是锁定该对象),其他尝试进入该同步代码块的线程就必须等待,直到当前线程退出代码块并释放监视器。
synchronized (同步监视器) { // 需要被同步的代码块 }
-
这里的“同步监视器”是一个对象,它是用于锁定的对象,可以是任何对象,但通常情况下会使用可能被多个线程共享访问的对象作为监视器。现在我们修改一下刚才的代码,添加同步代码块。这时再运行程序就不会出现数据不一致和数据越界的问题了。
public class Sale extends Thread { // 定义一个变量,用来记录销售的数量 static int count = 0; // 定义一个锁对象,用来控制线程的同步 static Object lock = new Object(); @Override public void run() { while (true) { // 加锁 只有获得锁的线程才能执行后续代码 否则就会阻塞等待 synchronized (lock) { if (count < 100) { try { sleep(10); } catch (InterruptedException e) { throw new RuntimeException(e); } count++; System.out.println(Thread.currentThread().getName() + " 销售了第 " + count + " 个商品"); } else { break; } } } } }
持续更新中。。。