【Java学习笔记十二】——初窥多线程

声明:本文章内容主要摘选自尚硅谷宋红康Java教程、《Java核心卷一》、廖雪峰Java教程,示例代码部分出自本人,更多详细内容推荐直接观看以上教程及书籍,若有错误之处请指出,欢迎交流。

一、多线程的概念

​ 读者可能已经很熟悉操作系统中的多任务(multitasking):在同一刻运行多个程序的能力。例如,在编辑或下载邮件的同时可以打印文件。今天,人们很可能有单台拥有多个CPU的计算机,但是,并发执行的进程数目并不是由CPU数目制约的。操作系统将CPU的时间片分配给每一个进程,给人并行处理的感觉。

​ 多线程程序在较低的层次上扩展了多任务的概念:一个程序同时执行多个任务。通常,每一个任务称为一个线程(thread),它是线程控制的简称。可以同时运行一个以上线程的程序称为多线程程序(multithreaded)

​ 那么,多进程与多线程有哪些区别呢?本质的区别在于每个进程拥有自己的一整套变量,而线程则共享数据。这听起来似乎有些风险,的确也是这样,在稍后将可以看到这个问题。然而,共享变量使线程之间的通信比进程之间的通信更有效、更容易。此外,在有些操作系统中,与进程相比较,线程更“轻量级”,创建、撤销一个线程比启动新进程的开销要小得多。进程和线程的关系就是:一个进程可以包含一个或多个线程,但至少会有一个线程。

在实际应用中,多线程非常有用。例如,一个浏览器可以同时下载几幅图片。一个Web服务器需要同时处理几个并发的请求。图形用户界面(GUI)程序用一个独立的线程从宿主操作环境中收集用户界面的事件。

二、创建多线程

1.创建

方式一:继承于Thread类

  1. 创建一个继承于Thread类的子类
  2. 重写Thread类的run()-->将此线程执行的操作声明在run()中
  3. 创建Thread类的子类的对象
  4. 通过此对象调用start()

方式二:实现Runnable接口

  1. 创建一个实现了Runnable接口的类
  2. 实现类去实现Runnable中的抽象方法:run()
  3. 创建实现类的对象
  4. 将此对象作为参数传送到Thread类的构造器中,创建Thread类的对象
  5. 通过Thread类的对象调用start()
public class Main {
    public static void main(String[] args) {
        MyThread1 t = new MyThread1();
        MyThread2 q = new MyThread2();
        Thread p = new Thread(q);

        t.setName("Ⅰ线程一");//设置线程名称
        p.setName("Ⅱ线程二");//设置线程名称
        t.start();
        p.start();
    }
}
//方式一创建多线程
class MyThread1 extends Thread{
    public void run(){
        for (int i = 0; i < 100; i++) {
            if(i % 2 == 0) System.out.println(Thread.currentThread().getName() + ":" + i);
        }
    }
}
//方式二创建多线程
class MyThread2 implements Runnable{

    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            if(i % 2 == 0) System.out.println(Thread.currentThread().getName() + ":" + i);
        }
    }
}

注意:不要调用Thread类或Runnable对象的run方法。直接调用run方法,只会执行同一个线程中的任务,以串行的方式进行,实际上运行的线程是main而不会启动新线程。应该调用Thread.start方法。这个方法将创建一个执行run方法的新线程。

2.线程的状态

在Java程序中,一个线程对象只能调用一次start()方法启动新线程,并在新线程中执行run()方法。一旦run()方法执行完毕,线程就结束了。因此,Java线程的状态有以下几种:

  • New:新创建的线程,尚未执行;
  • Runnable:运行中的线程,正在执行run()方法的Java代码;
  • Blocked:运行中的线程,因为某些操作被阻塞而挂起;
  • Waiting:运行中的线程,因为某些操作在等待中;
  • Timed Waiting:运行中的线程,因为执行sleep()方法正在计时等待;
  • Terminated:线程已终止,因为run()方法执行完毕。

线程终止的原因有:

  • 线程正常终止:run()方法执行到return语句返回;
  • 线程意外终止:run()方法因为未捕获的异常导致线程终止;
  • 对某个线程的Thread实例调用stop()方法强制终止(强烈不推荐使用)。

一个线程还可以等待另一个线程直到其运行结束。例如,main线程在启动t线程后,可以通过t.join()等待t线程结束后再继续运行。

3.解决线程安全问题

如果多个线程同时读写共享变量,会出现数据不一致的问题。例如采用4个线程售卖100张彩票,在运行的时候会出现彩票的数量变成负数的情况,原因就是各个线程的数据并不同步,我们称之为线程不安全,以下有几种方法可以解决这个问题。

方式一:同步代码块

首先我们先来知道锁的概念。多线程模型下,要保证逻辑正确,对共享变量进行读写时,必须保证一组指令以原子方式执行:即某一个线程执行时,其他线程必须等待。通过加锁和解锁的操作,就能保证在一个线程执行期间,不会有其他线程会进入此指令区间。即使在执行期线程被操作系统中断执行,其他线程也会因为无法获得锁导致无法进入此指令区间。只有执行线程将锁释放后,其他线程才有机会获得锁并执行。可见,保证一段代码的原子性就是通过加锁和解锁实现的。Java程序使用synchronized关键字对一个对象进行加锁:

synchronized(同步监视器){//需要被同步的代码}

public class Thread2 {
    public static void main(String[] args) {
        myThread3 queue = new myThread3();
        Thread window1 = new Thread(queue);
        Thread window2 = new Thread(queue);
        Thread window3 = new Thread(queue);

        window1.setName("1号窗口");
        window2.setName("2号窗口");
        window3.setName("3号窗口");

        window1.start();
        window2.start();
        window3.start();
    }
}
class myThread3 implements Runnable{
    private volatile int ticket = 100;
    private volatile Object obj = new Object();

    public void run(){

        while(true){
            synchronized(obj){      
                if(ticket > 0){
                    try{
                        Thread.sleep(100);
                    }catch(InterruptedException e){
                        e.printStackTrace();
                    }

                    System.out.println(Thread.currentThread().getName() + ":卖票,票号:" + ticket);
                    ticket--;
                }else{
                    break;
                }
            }
        }
    }
}

线程间共享变量需要使用volatile关键字标记,确保每个线程都能读取到更新后的变量值。

为什么要对线程间共享的变量用关键字volatile声明?这涉及到Java的内存模型。在Java虚拟机中,变量的值保存在主内存中,但是,当线程访问变量时,它会先获取一个副本,并保存在自己的工作内存中。如果线程修改了变量的值,虚拟机会在某个时刻把修改后的值回写到主内存,但是,这个时间是不确定的!这会导致如果一个线程更新了某个变量,另一个线程读取的值可能还是更新前的。例如,主内存的变量a = true,线程1执行a = false时,它在此刻仅仅是把变量a的副本变成了false,主内存的变量a还是true,在JVM把修改后的a回写到主内存之前,其他线程读取到的a的值仍然是true,这就造成了多线程之间共享的变量不一致。

因此,volatile关键字的目的是告诉虚拟机:

  • 每次访问变量时,总是获取主内存的最新值;
  • 每次修改变量后,立刻回写到主内存。

volatile关键字解决的是可见性问题:当一个线程修改了某个共享变量的值,其他线程能够立刻看到修改后的值。

方式二:同步方法

class myThread3 implements Runnable{
    private volatile int ticket = 100;
    private volatile Object obj = new Object();

    public void run(){
        while(true){
            sale();
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        }
    }
    public synchronized void sale(){
        if(tickets > 0) {
            System.out.println(Thread.currentThread().getName() + ":卖票,票号:" + ticket--);
        }
    }
}

方式三:线程池

使用线程池创建线程,有以下好处:(此处简略介绍,后面会有另外一篇笔记涉及到线程池)

  • 提高响应速度(减少了创建新线程的时间)

  • 降低资源消耗(重复利用线程池中线程,不需要每次都创建)

  • 便于线程管理

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
class MyThread4 implements Runnable {

    @Override
    public void run() {
        for (int i = 0; i < 20; i++) {
            if(i % 2 == 0) {
                System.out.println(Thread.currentThread().getName() + ":" + i);
            }
        }
    }
}
public class Thread3 {
    public static void main(String[] args) {
        //1.提供指定线程数量的线程池
        ExecutorService service = Executors.newFixedThreadPool(10);
     
        //2.执行指定的线程的操作。需要提供Runnable接口实现类的对象
        service.execute(new MyThread4());
       // service.submit();//适合用于Callable

        service.shutdown();//关闭线性池
    }
}

4.守护线程

守护线程是指为其他线程服务的线程。在JVM中,所有非守护线程都执行完毕后,无论有没有守护线程,虚拟机都会自动退出。

因此,JVM退出时,不必关心守护线程是否已结束。

如何创建守护线程呢?方法和普通线程一样,只是在调用start()方法前,调用setDaemon(true)把该线程标记为守护线程:

Thread t = new MyThread();t.setDaemon(true);t.start();

在守护线程中,编写代码要注意:守护线程不能持有任何需要关闭的资源,例如打开文件等,因为虚拟机退出时,守护线程没有任何机会来关闭文件,这会导致数据丢失。

普通线程的结束是以run方法结束;
守护线程的结束是以run方法或main函数结束.

import java.util.concurrent.TimeUnit;

public class ThreadTest3 {
	public static void main(String[] args) throws InterruptedException {
        MyThread1 t = new MyThread1();

        t.setDaemon(true);//守护线程
        t.start();
        Thread.sleep(2000);
        System.out.println(Thread.currentThread().getName() + " is exiting");
    }
}
class MyThread1 extends Thread{
    public void run() {
        while(true){
            System.out.println(Thread.currentThread().getName() + " is running");
            try {
                TimeUnit.SECONDS.sleep(1);//与Thread.sleep()类似,只是单位换成了秒
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

三、死锁

一个线程可以获取一个锁后,再继续获取另一个锁,在获取多个锁的时候,不同线程获取多个不同对象的锁可能导致死锁。
此时,两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。
死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程。

public void add(int m) {
    synchronized(lockA) { // 获得lockA的锁
        this.value += m;
        synchronized(lockB) { // 获得lockB的锁
            this.another += m;
        } // 释放lockB的锁
    } // 释放lockA的锁
}
public void dec(int m) {
    synchronized(lockB) { // 获得lockB的锁
        this.another -= m;
        synchronized(lockA) { // 获得lockA的锁
            this.value -= m;
        } // 释放lockA的锁
    } // 释放lockB的锁
}

在获取多个锁的时候,不同线程获取多个不同对象的锁可能导致死锁。对于上述代码,线程1和线程2如果分别执行add()dec()方法时:

  • 线程1:进入add(),获得lockA
  • 线程2:进入dec(),获得lockB

随后:

  • 线程1:准备获得lockB,失败,等待中;
  • 线程2:准备获得lockA,失败,等待中。

此时,两个线程各自持有不同的锁,然后各自试图获取对方手里的锁,造成了双方无限等待下去,这就是死锁。

死锁发生后,没有任何机制能解除死锁,只能强制结束JVM进程。

因此,在编写多线程应用时,要特别注意防止死锁。因为死锁一旦形成,就只能强制结束进程。

那么我们应该如何避免死锁呢?答案是:线程获取锁的顺序要一致。即严格按照先获取lockA,再获取lockB的顺序,改写dec()方法如下:

public void dec(int m) {
    synchronized(lockA) { // 获得lockA的锁
        this.value -= m;
        synchronized(lockB) { // 获得lockB的锁
            this.another -= m;
        } // 释放lockB的锁
    } // 释放lockA的锁
}

下一篇笔记会涉及多线程的高级操作,如并发框架Executor,Fork-Join等,若不满足于以上操作,可以看下一篇笔记~

最后附一下相关链接:
Java在线API中文手册
Java platform se8下载
尚硅谷Java教学视频
《Java核心卷一》

posted @ 2020-11-16 21:44  洛水凌云  阅读(78)  评论(0编辑  收藏  举报