本片篇文章将会对Java中多线程的内容进行总结,主要包括以下内容:
- 与线程相关的一些基础概念
- 线程的四种创建方式
- 线程之间的同步问题
- 线程之间的通信
线程相关的几个基本概念
首先,我们计算机的功能都是程序执行的结果,所谓的程序就是为完成特定任务、使用某种语言编写的一组指令的集合。即指一段静态的代码,静态对象。
进程
所谓的进程就是程序的一次执行过程,或是正在运行的一个程序。是一个动态的过程:有它自身的产生、存在和消亡的过程–生命周期。我们在windows系统中通过任务管理器就可以看到电脑中现在正在进行的进程。进程之间是相互独立存在,不能直接的进行通信,一个进程发生错误一般也不会影响到另外一个进行的执行情况。
线程
一个进程可以进一步细分为一个线程,是一个程序内部的一条执行路径。如果一个进程同一时间并行执行多个线程,那么就可以说这个进程是多线程的。例如火车站的售票系统就是一个多线程的,它需要支持多个窗口的购票和退票等业务。线程可以作为调度和执行的单位,每个线程拥有独立的运行栈和程序计数器,且线程直接的切换对系统的开销是非常小的。
一个进程中的多个线程共享相同的内存单元/内存地址空间(它们从同一堆中分配对象),可以访问相同的变量和对象。这就使得线程间通信更简便、高效。但多个线程操作共享的系统资源可能就会带来安全的隐患。例如如果火车站的售票系统的多个线程之间没有进行合理的通信,那么就有可能会出现重票、错票的情况,所以多线程虽然可以极大的提高程序的性能,但是如果没有控制好就会出现错误。
一个线程具有以下几个阶段,我们称为线程的生命周期:
- 新建:当一个 Thread类或其子类的对象被声明并创建时,新生的线程对象处于新建。
- 就绪:处于新建状态的线程被start()后,将进入线程队列等待CPU时间片,此时它已具备了运行的条件,只是没分配到CPU资源。
- 运行:当就绪的线程被调度并获得cPU资源时,便进入运行状态,run()方法定义了线程的操作和功能
- 阻塞:在某种特殊情况下,被人为挂起或执行输入输出操作时,让出CPU并临时中止自己的执行,进入阻塞状态
- 死亡:线程完成了它的全部工作或线程被提前强制性地中止或出现异常导致结束
线程状态之间的切换关系图,如下图所示:

单核与多核
所谓的单核就是指同一单位时间内,只能执行一个线程,而多核是指同一单位时间内,可以执行多个线程,多核可以更好的发挥多线程的优势与效率,这也是为什么核数越高的cpu往往具有较高的性能的原因。一个java应用程序Java.exe至少有三个线程:main()主线程,gc()垃圾回收线程,异常处理线程。
并发与并行
并行就是多个CPU同时执行多个任务。比如:多个人同时做不同的事。并发就是一个CPU(采用时间片)同时执行多个任务。比如:秒杀、多个人做同一件事,鸣人的影分身术。
线程的创建的使用
在Java中创建线程一共有四种方式
方式一:继承于Thread类
使用Thread类创建线程可以分为以下几个步骤:
- 创建一个继承于Thread类的子类
- 重写Tread类的run()方法
- 创建Tread类的子类的对象
- 通过对象调用start()方法:该方法将会启动线程,并调用当前线程的run方法
下面就是一个使用该方法创建一个线程的实例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 public static void main(String[] args) {
//第一种方式
ThreadTest t1 = new ThreadTest();
t1.start();
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName()+": hello");
}
}
class ThreadTest extends Thread{
public void run() {
for(int i = 0;i<10;i++){
if(i%2==0){
System.out.println(Thread.currentThread().getName()+": "+i);
}
}
}
}
该示例的一个ThreadTest类用于输出10以内的偶数,并且在main方法中也另外输出了十次的hello,这里如果按照正常的程序从上到下的顺序将会是现输出偶数,再输出hello。但是执行结果却是下面的情况:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15main: hello
main: hello
main: hello
main: hello
main: hello
main: hello
main: hello
main: hello
main: hello
main: hello
Thread-0: 0
Thread-0: 2
Thread-0: 4
Thread-0: 6
Thread-0: 8
这里先输出的是hello。显然并不是按照从上到下的循序执行的,这就是因为再main主进程中加入了一个分线程,实际上了如果输出的数据更多,可能还会出现两个线程交替执行的情况。
Thread类中有一些常用的方法
- 1.start():启动当前线程;调用当前线程的run()
- 2.run():通常需要重写 Thread类中的此方法,将创建的线程要执行的操作声明在此方法中
- 3.currentThread():静态方法,返回执行当前代码的线程
- 1.getName():获取当前线程的名字
- 5.setName():设置当前线程的名字
- 6.yield():释放当前cpu的执行权
- 7.join():在线程a中调用线程b的join(),此时线程a就进入阻塞状态,直到线程b完全执行完后,线程a才结束阻塞状态。
- 8.stop():已过时。当执行此方法时,强制结束当前线程
- 9.sleep( Long millitime):让当前线程阻塞指定 millitime毫秒。在指定的 millitime毫秒时间内,当前线程是阻塞状态。
- 10.isAlive():判断当前线程是否存活
使用这种方法创建线程有一个比较大的局限性就是子类将不能再继承其他的父类。
方式二:实现Runnable接口
使用该方法创建线程主要分为以下几个步骤:
- 1.创建一个实现了 Runnable接口的类
- 2.实现类去实现 Runnable中的抽象方法:run()
- 3.创建实现类的对象
- 4.将此对象作为参数传到 Thread类的构造器中,创建 Thread类的对象
- 5.通过Thread类的对象调用 start()
下面就是一个使用该方法创建的一个示例:
1 | class ThreadTest2 implements Runnable{ |
这里为什么再Thread类的构造器中传入t2,再执行Thread类的run方法,执行的确是我们的方法呢?
这是因为Thread本身也实现了Runnable接口,而在Thread类中定义的start方法调用了其run方法:
1 | public void run() { |
而这里的target变量的什么是一个runnable的:
1 | private Runnable target; |
所以,run方法实际上调用的也就是我们重写的run方法。
使用这种方式创建线程没有了java单继承的限制,实现类依然可以继承其他的父类,也更适合处理多个线程之间有共享数据的情况。
方式三:实现callable接口
使用该方法的创建线程的基本步骤如下:
- 1.创建一个实现callable的实现类
- 2、实现call方法,将此线程需要执行的操作声明在call中
- 3、创建callable接口实现类的对象
- 4.将此callable接口实现类的对象作为传递到FutureTask构造器中,创建FutureTask对象
- 5.将FutureTask的对象作为参数传递到Thread类的构造器中。创建Thread对象
- 6.get方法可以返回FutureTask构造器参数Callable实现类重写的返回值(非必须)
下面是一个用该方法创建线程的示例:
1 | public class ThreadNew { |
实现Callable接口要比Runnable接口更加强大,原因是:
- call()允许有返回值,而run()方法是不可以有返回值的
- call()方法可以抛出异常,可以被外面的操作捕获,获取到异常的信息
- call()方法是支持泛型的。
方式四:使用线程池
使用前面的方法创建线程的时候,如果需要再创建一个线线程是就需要额外的创建,即使用一个创建一个。
而使用线程池的方法创建线程,是提前创建了多个线程,如果需要线程就可以直接从线程池里面拿,用完之后再放回到线程池就行,这样就减少了创建新线程的的时间,提高了响应速度;而且由于线程池里面的线程是可以重复利用的,这就可以降低对资源的消耗。
且使用线程池更方便对线程进行管理,例如:
- corePoolSize:核心池的大小
- maximumPoolSize:最大线程数
- keepAliveTime:线程没有任务时最多保持多长时间后终止
使用线程池需要用到两个类:
- Executors:工具类、线程池的工厂类,用于创建并返回不同类型的线程池,其里面的方法均是静态方法。ExecutorService :真正的线程池接口。
1
2
3
4Executors.newCached ThreadPool():创建一个可根据需要创建新线程的线程池
Executors.newFixedThreadPool(n)创建一个可重用固定线程数的线程泡
Executors.newSingleThread Executor()创建一个只有一个线程的线程池
Executors.newScheduledThreadPool(n):创建一个线程池,它可安排在给定延迟后运行命令或者定期地执行使用创建线程池的一般步骤如下:1
2
3void execute( Runnable command):执行任务/命令,没有返回值,一般用来执行Runnable
<T> Future<T> submit( Callable<T>task):执行任务,有返回值,一般又来执行Callable
void shutdown():关闭连接池 - 1、提供指定线程数量的线程池
- 2、执行指定的线程的操作,需要提供实现了callable或runnable接口类的对象
- 3.关闭连接池
下面是一个实例:
1 | public class ThreadPool { |
线程的调度
虽然线程可以一次执行多个,但是如果当需要操作共享数据时,还是需要一次执行的。这是就需要决定一下那个线程可以优先执行了。
再Java中的调度策略是:
- 同优先级线程组成先进先出队列(先到先服务),使用时间片策略
- 对高优先级,使用优先调度的抢占式策略
这是就涉及到优先级的设置了,线程优先级的设置方法是:
- getPriority():返回线程优先值
- setPriority( int newPriority):改变线程的优先级
线程的等级划分是:
- 最高:MAX PRIORITY: 10
- 最低:MIN PRIORITY: 1
- 默认:NORM PRIORITY: 5
1 | public class priorityTest { |
该示例将分线程的优先级设置为最大,而将主线程的优先级设置为最低,其执行结果是:
1 | 分线程: 0 |
可以看到现在分线程再主线程之前执行了。
在设置线程的优先级是需要注意以下两个问题:
- 线程创建时继承父线程的优先级
- 低优先级只是获得调度的概率低,并非一定是在高优先级线程之后才被调用。
线程的同步
前面的案例虽然说都是使用了线程,但是没有依然没有使用到多线程,所以也没有表现出线程的安全问题。但是上面的程序都不是线程安全的,容易发生线程的安全问题。
但是例如下面的模拟卖票程序,就回出现问题了:
1 | class WindowTest1 implements Runnable{ |
上面的程序模拟了通过三个进程来卖出100张票,会出现下面的情况:
1 | 窗口2: 卖票,票号:100 |
三个窗口都卖出去了票号为100的票,这显然是有问题的,为什么会出现这样的问题呢?
这是因为三个线程同一时间对共同数据ticket进行了操作,为了解决这个问题,就不能让ticket同时被多个线程操作。每次只有一个线程对其操作,操作完了之后再让下一个进行进来。
java中解决线程安全的问题有三种方法:
方式一:同步代码块
其基本语法为:
1 | synchronized(同步监视器){ |
其中:
- 需要同步的代码:操作共享数据的代码
- 共享数据:多个线程共同操作的数据
- 同步监视器(锁):任何类的对象都可以充当锁;多个线程必须要共同一把锁
下面的代码就可以使用同步代码块的方式解决同步问题:
1 | private static int ticket = 100; |
这里使用关键字this来充当同步监视器,使用为这三个线程都是使用的是同一个对象(w),但是如果是使用Thread类的创建的线程就不可已使用thi关键字了,因为此时的this关键字指代的是三个不同的对象,此时可以使用:类名.class来充当唯一的锁。
同步监视器可以是任意一个对象,但是必须保证这个同步监视器是唯一的
方式二:使用同步方法的方式
其基本语法是:
1 | 权限修饰符 (static) synchronized 方法名{} |
注意到这里虽然也使用了synchroized,但是并没有显式的申明使用的是同步监视器,而是使用了默认的同步监视器。
- 非静态同步方法的同步监视器时this
- 静态同步方法的同步监视器时当前类本身
下面的代码就,使用同步方法的方式解决了线程的同步问题:
1 | class Window4 extends Thread { |
方式三:使用Lock
该方式可以分为三个步骤:
- 1.创建lock对象
- 2.在使用同步资源前调用锁定方法lock
- 3.调用解锁方法unlock
1 | class Window implements Runnable{ |
这里的unlock需要放在finall结构中,不然如果出现异常,可能会使程序出现死锁。
死锁问题
所谓的死锁问题,就是不同的线程分别占用对方需要的同步资源不放弃,都在等待对方放弃自己需要的同步资源,就形成了线程的死锁。经典的死锁问题有:生产者-消费者问题、哲学家用餐问题等。出现死锁后,不会出现异常,不会出现提示,只是所有的线程都处于阻塞状态,无法继续。
想要解决死锁的问题可以使用专门的算法或者原则,同时再设计线程的时候尽量减少同步资源的定义,尽量避免嵌套同步。
下面一个示例来解决生产者-消费者问题:
1 | 生产者 Productor)将产品交给店员( Clerk),而消费者( Customer)从店员处取走产品,店员一次只能持有固定数量的产品(比如:20),如果生产者试图生产更多的产品,店员会叫生产者停一下,如果店中有空位放产品了再通知生产者继续生产;如果店中没有产品了,店员会告诉消费者等一下,如果店中有产品了再通知消费者来取走产品。 |
程序:
1 | class Clerk{ |
线程之间的通信
线程之间的通指的是不同的线程之间能够进行协作,例如前面的生产者-消费者问题就是一个线程之间通信的实例,从这个例子中也可以看出几个不同线程之间能够进行通信时非常重要的。
关于线程之间通信一般涉及到三个方法:
- wait():一旦执行此方法,当前线程就进入阻塞状态,并释放同步监视器。
- notify():一旦执行此方法,就会唤醒被wait的一个线程。如果有多个线程被wait,就唤醒优先级高的线程
- notifyAll():一且执行此方法,就会唤醒所有wait的线程。
在使用这三个方法时,需要注意以下几个方面:
- 1.wait()、notify()、notifyall()三个方法必须使用在同步代码块或同步方法中
- 2.wait()、notify()、notifyall()三个方法的调用者必须是同步代码块或同步方法中的同步监视,否则,会出现 Lega LMonitorstateException异常
- 3.wait()、notify()、notifyall()三个方法是定义在java.lang.0bject类中
下面一个实例就实现了使用两个线程交替执行,打印0~100的数
1 | public class CommunicationTest { |
扩展:sleep方法和wait方法的区别
sleep方法和wait方法都是使当前线程阻塞,但是这两个方法也有以下的不同:
- 两个方法声明的位置不同:Thread类中声明sleep,Object类声明wait
- 是否释放同步监视器:sleep不会是释放同步监视器;wait会释放同步监视器
- 调用的要求不同:sleep可以在任何需要的场景下调用;wait必须使用在同步代码块或同步方法中
本文链接: https://quandongli.github.io/post/4501de25.html
版权声明: 本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。转载请注明出处!
