Java基础回顾——多线程

介绍

计算机中,一个任务称为一个进程,某些进程内部还需要同时执行多个子任务,子任务称为线程

一个进程可以包含一个或多个线程,但至少会有一个线程

操作系统调度的最小单位是线程,Windows和Linux都采用抢占式多任务,如何调度线程由操作系统决定

特点:

  • 创建进程的开销大,但进程稳定性高,一个进程崩溃不会影响其他进程
  • 任何一个线程崩溃会直接导致整个进程崩溃,线程间通信快

Java程序实际上是JVM进程,JVM进程用一个主线程来执行main()方法,在main()方法内部,可以启动多个线程。JVM还有负责垃圾回收的其他工作线程。

创建新线程

创建新线程需要实例化Thread实例,然后调用它的start()方法

package ThreadTest;

public class ThreadStudy {
    public static void main(String[] args) {
        Thread t=new Thread();
        t.start();
    }
}

如果希望新线程能执行指定的代码:
1、从Thread派生一个自定义类,覆写run()方法
2、创建Thread实例时,传入一个Runnable实例

package ThreadTest;

public class ThreadStudy {
    public static void main(String[] args) {
        Thread t=new Thread();
        t.start();

//        方法1
        MyThread myThread=new MyThread();
        myThread.start();

//        方法2
        Thread thread=new Thread(new MyRunnable());
        thread.start();

//        方法2 lambda写法
        Thread thread2=new Thread(()->{
           System.out.println("start thread2 lambda!");
        });
        thread2.start();
        
//        start mythread!
//        start myRunnable!
//        start thread2 lambda!
    }
}

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("start mythread!");
    }
}

class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("start myRunnable!");
    }
}

线程优先级

Thread.setPriority(int n);//1~10,默认5,1最低

优先级高的线程被操作系统调度的优先级高,操作系统对高优先级线程可能调度更频繁,但不能通过设置优先级来确保高优先级的线程一定会先执行。

线程的状态

Java线程的状态有以下几种:

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

在这里插入图片描述线程启动后,在四个状态切换,直到变成terminated状态,线程终止

线程终止的原因:

  • 正常终止,run()执行到return返回
  • 意外终止,run()因为没捕获异常导致线程终止
  • stop(),对某个线程实例调用stop()方法强制终止

t.join()方法可以让一个线程等待t结束后执行

中断线程

t.interrupt()方法可以中断线程

interrupt()方法仅仅向t线程发出了“中断请求”,至于t线程是否能立刻响应,要看具体代码。

另一个常用的中断线程的方法是设置标志位。通常会用一个running标志位来标识线程是否应该继续运行,在外部线程中,通过把HelloThread.running置为false,就可以让线程结束

public class Main {
    public static void main(String[] args)  throws InterruptedException {
        HelloThread t = new HelloThread();
        t.start();
        Thread.sleep(1);
        t.running = false; // 标志位置为false
    }
}

class HelloThread extends Thread {
    public volatile boolean running = true;
    public void run() {
        int n = 0;
        while (running) {
            n ++;
            System.out.println(n + " hello!");
        }
        System.out.println("end!");
    }
}

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

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

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

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

守护线程

所有线程都运行结束,JVM退出,进程结束。

有一种线程的目的是无限循环,其他线程结束,JVM想要结束,就要有专门的线程负责结束这个无限循环的线程。

这种就是守护线程(Daemon Thread)

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

创建守护线程

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

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

守护线程不能持有任何需要关闭的资源,因为虚拟机退出时,守护线程没有机会关闭文件,会导致数据丢失。

线程同步

多个线程同时运行时,线程的调度由操作系统决定,程序本身无法决定。

如果多个线程同时读写共享变量,会出现数据不一致的问题。

对变量进行读取和写入时,结果要正确,必须保证是原子操作。原子操作是指不能被中断的一个或一系列操作。

通过加锁和解锁的操作,能保证多条指令总在一个线程执行期间,不会有其他线程会进入此指令区间。即使在执行期线程被操作系统中断执行,其他线程也会因为无法获得锁导致无法进入此指令区间。只有执行线程将锁释放后,其他线程才有机会获得锁并执行。这种加锁和解锁之间的代码块称之为临界区(Critical Section),任何时候临界区最多只有一个线程能执行。

Java程序使用synchronized关键字对一个对象进行加锁:

synchronized(lock) {
    n = n + 1;
}
package ThreadTest;

public class ThreadSync {
    public static void main(String[] args) throws InterruptedException {
        Thread add = new AddThread();
        Thread dec = new DecThread();
        add.start();
        dec.start();
        add.join();
        dec.join();
        System.out.println(Counter.count);
    }
}

class Counter{
    public static final Object lock = new Object();
    public static int count=0;
}

class AddThread extends Thread{
    @Override
    public void run() {
        for (int i=0;i<10000;i++){
            synchronized (Counter.lock) {
                Counter.count+=1;
            }
        }
    }
}

class DecThread extends Thread{
    @Override
    public void run() {
        for(int i=0;i<10000;i++){
            synchronized (Counter.lock){
                Counter.count-=1;
            }
        }
    }
}

表示用Counter.lock实例作为锁,两个线程在执行各自的synchronized(Counter.lock) { … }代码块时,必须先获得锁,才能进入代码块进行。执行结束后,在synchronized语句块结束会自动释放锁。这样一来,对Counter.count变量进行读写就不可能同时进行。

使用synchronized解决了多线程同步访问共享变量的正确性问题。但是,它的缺点是带来了性能下降。因为synchronized代码块无法并发执行。此外,加锁和解锁需要消耗一定的时间,所以,synchronized会降低程序的执行效率。

不需要synchronized的操作

JVM规范定义了几种原子操作:

  • 基本类型(long和double除外)赋值,例如:int n = m;
  • 引用类型赋值,例如:List list = anotherList。

同步方法

让线程自己封锁对象会使代码逻辑混乱,也不利于封装,更好的方法是把synchronized逻辑封装起来。

public class Counter {
    private int count = 0;

    public void add(int n) {
        synchronized(this) {
            count += n;
        }
    }

    public void dec(int n) {
        synchronized(this) {
            count -= n;
        }
    }

    public int get() {
        return count;
    }
}

synchronized锁住的对象是this,即当前实例,这会使得创建多个实例的时候,它们之间互不影响,可以并发执行

如果一个类被设计为允许多线程正确访问,就说这个类是”线程安全的“

Java标准库的java.lang.StringBuffer,一些不变类,例如String,Integer,LocalDate,所有的成员变量都是final,多线程同时访问时只能读不能写,也是线程安全的

类似Math这些只提供静态方法,没有成员变量的类,也是线程安全的

当锁住的是this实例时,实际上可以用synchronized修饰这个方法

public void add(int n) {
    synchronized(this) { // 锁住this
        count += n;
    } // 解锁
}

public synchronized void add(int n) { // 锁住this
    count += n;
} // 解锁

对static方法添加synchronized,锁住的是该类的Class实例。

死锁

Java的线程锁是可重入锁,也就是JVM允许同一个线程重复获取同一个锁,
获取锁的时候,不但要判断是否是第一次获取,还要记录这是第几次获取。每获取一次锁,记录+1,每退出synchronized块,记录-1,减到0的时候,才会真正释放锁。

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的顺序

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

wait和notify

在synchronized内部可以调用wait()使线程进入等待状态;

必须在已获得的锁对象上调用wait()方法;

在synchronized内部可以调用notify()或notifyAll()唤醒其他等待线程;

必须在已获得的锁对象上调用notify()或notifyAll()方法;

已唤醒的线程还需要重新获得锁后才能继续执行。

ReentrantLock

synchronized关键字用于加锁,但这种锁一是很重,二是获取时必须一直等待,没有额外的尝试机制。

java.util.concurrent.locks包提供的ReentrantLock用于替代synchronized加锁

ReentrantLock是可重入锁,它和synchronized一样,一个线程可以多次获取同一个锁。

和synchronized不同的是,ReentrantLock可以尝试获取锁:

if (lock.tryLock(1, TimeUnit.SECONDS)) {
    try {
        ...
    } finally {
        lock.unlock();
    }
}

尝试获取锁的时候,最多等待1秒。如果1秒后仍未获取到锁,tryLock()返回false,程序就可以做一些额外处理,而不是无限等待下去。

condition

synchronized可以配合wait和notify实现线程在条件不满足时等待,条件满足时唤醒

ReentrantLock使用Condition对象来实现wait和notify的功能

class TaskQueue {
    private final Lock lock = new ReentrantLock();
    private final Condition condition = lock.newCondition();
    private Queue<String> queue = new LinkedList<>();

    public void addTask(String s) {
        lock.lock();
        try {
            queue.add(s);
            condition.signalAll();
        } finally {
            lock.unlock();
        }
    }

    public String getTask() {
        lock.lock();
        try {
            while (queue.isEmpty()) {
                condition.await();
            }
            return queue.remove();
        } finally {
            lock.unlock();
        }
    }
}
  • await()会释放当前锁,进入等待状态;

  • signal()会唤醒某个等待线程;

  • signalAll()会唤醒所有等待线程;

ReadWriteLock

ReentrantLock保证了只有一个线程可以执行临界区代码,任何时刻,只允许一个线程修改,但get操作实际上允许多个线程同时调用

ReadWriteLock可以解决这个问题,它保证:

  • 只允许一个线程写入(其他线程既不能写入也不能读取);
  • 没有写入时,多个线程允许同时读(提高性能)。
public class Counter {
    private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
    private final Lock rlock = rwlock.readLock();
    private final Lock wlock = rwlock.writeLock();
    private int[] counts = new int[10];

    public void inc(int index) {
        wlock.lock(); // 加写锁
        try {
            counts[index] += 1;
        } finally {
            wlock.unlock(); // 释放写锁
        }
    }

    public int[] get() {
        rlock.lock(); // 加读锁
        try {
            return Arrays.copyOf(counts, counts.length);
        } finally {
            rlock.unlock(); // 释放读锁
        }
    }
}

StampedLock

ReadWriteLock解决了多线程同时读,但只有一个线程能写的问题,
但是如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的锁

要进一步提升并发执行效率,Java 8引入了新的读写锁:StampedLock。

StampedLock和ReadWriteLock相比,改进之处在于:读的过程中也允许获取写锁后写入

这样读的数据就可能不一致,所以,需要一点额外的代码来判断读的过程中是否有写入,这种读锁是一种乐观锁。

乐观锁的意思就是乐观地估计读的过程中大概率不会有写入,因此被称为乐观锁。反过来,悲观锁则是读的过程中拒绝有写入,也就是写入必须等待。

public class Point {
    private final StampedLock stampedLock = new StampedLock();

    private double x;
    private double y;

    public void move(double deltaX, double deltaY) {
        long stamp = stampedLock.writeLock(); // 获取写锁
        try {
            x += deltaX;
            y += deltaY;
        } finally {
            stampedLock.unlockWrite(stamp); // 释放写锁
        }
    }

    public double distanceFromOrigin() {
        long stamp = stampedLock.tryOptimisticRead(); // 获得一个乐观读锁
        // 注意下面两行代码不是原子操作
        // 假设x,y = (100,200)
        double currentX = x;
        // 此处已读取到x=100,但x,y可能被写线程修改为(300,400)
        double currentY = y;
        // 此处已读取到y,如果没有写入,读取是正确的(100,200)
        // 如果有写入,读取是错误的(100,400)
        if (!stampedLock.validate(stamp)) { // 检查乐观读锁后是否有其他写锁发生
            stamp = stampedLock.readLock(); // 获取一个悲观读锁
            try {
                currentX = x;
                currentY = y;
            } finally {
                stampedLock.unlockRead(stamp); // 释放悲观读锁
            }
        }
        return Math.sqrt(currentX * currentX + currentY * currentY);
    }
}

获取乐观读锁,返回版本号,验证版本号,成功就继续后续操作,如果读过程有写入,版本号变化,通过获取悲观读锁再次读取

Semaphore

锁的目的是保护一种受限资源,保证同一时刻只有一个线程能访问(ReentrantLock),或者只有一个线程能写入(ReadWriteLock)

还有一种受限资源,它需要保证同一时刻最多有N个线程能访问,比如同一时刻最多创建100个数据库连接,最多允许10个用户下载等。

这种限制数量的锁,如果用Lock数组来实现,就太麻烦了。

这种情况就可以使用Semaphore

public class AccessLimitControl {
    // 任意时刻仅允许最多3个线程获取许可:
    final Semaphore semaphore = new Semaphore(3);

    public String access() throws Exception {
        // 如果超过了许可数量,其他线程将在此等待:
        semaphore.acquire();
        try {
            // TODO:
            return UUID.randomUUID().toString();
        } finally {
            semaphore.release();
        }
    }
}

线程池

创建线程需要操作系统资源,频繁创建和销毁大量线程需要消耗大量时间。

可以把很多小任务让一组线程来执行,而不是一个任务对于一个新线程,这种能接收大量小任务并进行分发处理的就是线程池。

线程池内部维护了若干个线程,没有任务的时候,这些线程都处于等待状态。有新任务,就分配一个空闲线程执行。所有线程都处于忙碌状态,新任务要么放入队列等待,要么增加一个新线程进行处理。

Java标准库提供了ExecutorService接口表示线程池

ExecutorService只是接口,Java标准库提供的几个常用实现类有:

  • FixedThreadPool:线程数固定的线程池;
  • CachedThreadPool:线程数根据任务动态调整的线程池;
  • SingleThreadExecutor:仅单线程执行的线程池。
import java.util.concurrent.*;

public class Main {
    public static void main(String[] args) {
        // 创建一个固定大小的线程池:
        ExecutorService es = Executors.newFixedThreadPool(4);
        for (int i = 0; i < 6; i++) {
            es.submit(new Task("" + i));
        }
        // 关闭线程池:
        es.shutdown();
    }
}

class Task implements Runnable {
    private final String name;

    public Task(String name) {
        this.name = name;
    }

    @Override
    public void run() {
        System.out.println("start task " + name);
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
        }
        System.out.println("end task " + name);
    }
}

ScheduledThreadPool

任务本身固定,需要反复执行的,可以使用ScheduledThreadPool。放入ScheduledThreadPool的任务可以定期反复执行。

Future

Runnable接口有个问题,它的方法没有返回值。如果任务需要一个返回结果,那么只能保存到变量,还要提供额外的方法读取,非常不便。所以,Java标准库还提供了一个Callable接口,和Runnable接口比,它多了一个返回值

class Task implements Callable<String> {
    public String call() throws Exception {
        return longTimeCalculation(); 
    }
}

ExecutorService executor = Executors.newFixedThreadPool(4); 
// 定义任务:
Callable<String> task = new Task();
// 提交任务并获得Future:
Future<String> future = executor.submit(task);
// 从Future获取异步执行返回的结果:
String result = future.get(); // 可能阻塞

一个Future接口表示一个未来可能会返回的结果,它定义的方法有:

  • get():获取结果(可能会等待)
  • get(long timeout, TimeUnit unit):获取结果,但只等待指定的时间;
  • cancel(boolean mayInterruptIfRunning):取消当前任务;
  • isDone():判断任务是否已完成。

CompletableFuture

Java 8开始引入了CompletableFuture,它针对Future做了改进,可以传入回调对象,当异步任务完成或者发生异常时,自动调用回调对象的回调方法。

CompletableFuture的优点是:

  • 异步任务结束时,会自动回调某个对象的方法;
  • 异步任务出错时,会自动回调某个对象的方法;
  • 主线程设置好回调后,不再关心异步任务的执行。
  • 38
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值