JAVA:创建线程,线程安全,线程锁,线程的生命周期,线程池

43 阅读9分钟

java中创建线程有多种方案:

继承Thread类

通过继承Thread类并重写其run方法来创建线程,新类的实例可以通过调用start方法开始执行run方法在一个全新的线程中。

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("MyThread is running");
    }
}
​
public class Main {
    public static void main(String[] args) {
        MyThread thread = new MyThread();
        thread.start(); // 启动线程
    }
}

实现Runnable接口

通过实现Runnable接口(Runnable接口也有run方法),并将其实例传递给Thread对象来创建线程。

class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("MyRunnable is running");
    }
}
​
public class Main {
    public static void main(String[] args) {
        Thread thread = new Thread(new MyRunnable());
        thread.start(); // 启动线程
    }
}

使用Callable接口和FutureTask

继承Callable接口创建新类,并创建一个实例传入FutureRask类的构造函数创建另一个实例,然后将其传递给Thread对象,其中真正执行的是call方法中的内容,与上面两者不同的是这个call的返回值可以通过调用FutureTask的实例的get方法获得。

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;
​
class MyCallable implements Callable {
    @Override
    public String call() throws Exception {
        return "MyCallable is running";
    }
}
​
public class Main {
    public static void main(String[] args) {
        FutureTask futureTask = new FutureTask<>(new MyCallable());
        Thread thread = new Thread(futureTask);
        thread.start(); // 启动线程

        try {
            String result = futureTask.get(); // 获取线程执行结果
            System.out.println(result);
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
    }
}

前两种书写简单,较好理解,都是通过实现run方法,再通过调用线程实力的start方法在新线程上执行run方法,不同的是第二种是实现的接口,而实现接口意味着它可以实现很多接口,并且继承其他类,有更好的扩展性(实际上也不常用),而第三种则比较复杂,传来传去,最终还要通过调用get方法获取线程的返回值,那他是怎么做到的呢?以下的源码实现:

可以看到整个过程极为粗劣,其实就是多传入了一个函数(Callable类的实例的call方法),然后在run方法中调用这个方法,并且保存这个方法的返回值,而get方法则是原地循环阻塞等待值,并且返回,整个过程非常简单,没有亮点。所以其实这个方法虽然复杂,但也不是很好,相比之下,还是第一种简单好用。(实际开发中还是直接用线程池的多)

操作线程的常用方法

1. Object类的方法

这些方法必须在同步块或同步方法(synchronized,后面会讲)中调用,否则会抛出IllegalMonitorStateException。

  • wait()
    • 使当前线程等待,直到其他线程调用同一对象的notify()或notifyAll()方法,或者等待时间超时。
synchronized (lock) {
    while (!condition) {
        lock.wait();  // 或 lock.wait(timeout);
    }
    // 继续执行
}
  • notify()
    • 唤醒调用此对象上的wait方法进入等待的单个线程。
synchronized (lock) {
    lock.notify();
}
  • notifyAll()
    • 唤醒调用此对象上的wait方法进入等待的所有线程。
synchronized (lock) {
    lock.notifyAll();
}

2. Thread类的方法

  • join()
    • 等待调用此方法的线程实例终止后在执行当前线程。
Thread t = new Thread(() -> {
    // 线程任务
});
t.start();
t.join();  // 等待 t 线程结束
  • sleep()
    • 使当前线程休眠指定时间。
try {
    Thread.sleep(1000);  // 休眠1秒
} catch (InterruptedException e) {
    e.printStackTrace();
}
  • yield()
    • 暂停当前正在执行的线程对象,并执行其他线程。
Thread.yield();
  • interrupt()
    • 中断线程,设置线程的中断标志(如果线程此时处于wait或sleep状态则直接报错)。
Thread t = new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        // 线程任务
    }
});
t.start();
t.interrupt();  // 中断 t 线程
  • isInterrupted()
    • 检查线程是否被中断。
if (t.isInterrupted()) {
    // 线程被中断
}
  • interrupted()
    • 检查当前线程是否被中断,并清除中断状态。
if (Thread.interrupted()) {
    // 当前线程被中断,且中断状态已被清除
}

多线程通过操作会引发数据安全问题,加入说淘宝购买一件东西,而这个东西库存只剩一个,那么当两个人同时购买时涉及的逻辑包括判断是否有库存,然后库存减一,如果一方检测有库存,但还没来的及减一,此时另一个人的线程也开始检测库存,发现也有库存,此时剩余的一个库存就会被减两次变成负一,为了避免这种情况,出现了线程锁的机制,线程锁就是通过给资源上锁,而只有拿到这个锁的线程才可以操作资源,以达到多线程同步操作的结果,上面说的同步块和同步方法(synchronized)就是其中之一,实现方法如下:

1.同步块

同步块通过定义一个代码块,并且通过小括号传入一个对象作为锁来实现(这个对象必须对于多个线程是相同的不变的)

public class Example {
    private final Object lock = new Object();
​
    public void someMethod() {
        synchronized (lock) {
            // 同步代码块
            // 只有持有lock对象锁的线程才能进入此代码块
            System.out.println("Thread " + Thread.currentThread().getName() + " is executing synchronized block.");
        }
    }
}

在上述代码中,synchronized (lock)表示当前代码块被lock对象锁保护,只有获得lock对象锁的线程才能进入该代码块执行。这种方式并不限定哪个线程来执行,只要某个线程持有了lock对象的锁,它就可以执行同步块中的代码。而其他的线程必须排队等候。

2.同步方法

同步方法用于同步整个方法,非静态方法的锁则是这个实例本身,而静态方法的锁则是这个类对象。

public class Example {
    public synchronized void someMethod() {
        // 同步方法
        // 只有持有当前对象锁的线程才能进入此方法
        System.out.println("Thread " + Thread.currentThread().getName() + " is executing synchronized method.");
    }

    public static synchronized void someStaticMethod() {
        // 静态同步方法
        // 只有持有该类类锁的线程才能进入此方法
        System.out.println("Thread " + Thread.currentThread().getName() + " is executing synchronized static method.");
    }
}

在上述代码中,someMethod()是一个实例同步方法,表示当前方法被该对象实例的锁保护。someStaticMethod()是一个静态同步方法,表示当前方法被该类的类锁保护。任何一个线程在调用这些同步方法时,必须先获取对应的对象锁或类锁。

有人可能会说同步块比同步方法好,因为同步块可以指定一部分代码代码逻辑上锁,而不用整个方法上锁,这就可以让尽可能多的代码逻辑让线程异步执行,提高性能,而同步方法则必须是整个方法,实则不然,实际上同步方法也可以讲内部一部分逻辑封装成一个方法变为同步方法,而取消外部的同步方法,一样能实现逻辑。

对于synchronized(同步块和同步方法机制相同,这里统一用字母代替),JVM做了大量优化:

偏向锁:当一个线程持有synchronized的锁时,并且之后没有其他线程尝试获取这把锁,那么该synchronized会一直将锁交给上一个进程,这样当上一次进程再次访问synchronized时,则不需要重复获取锁

轻量级锁和重量级锁:当一个进程获取锁失败时,会重新获取,这个过程叫自旋,整个状态叫轻量级锁。当自旋达到一定次数时,证明当前进程过多,此时进程会挂起,等待锁空闲后被唤醒,这个状态叫重量级锁。

锁消除:当JVM检测到一个锁并没有可能被其他线程获取时(也就是synchronized定义的没有意义),此时锁会被回收

锁粗化:当JVM检测到有一系列连续的获取锁的过程,那么他会将这些过程公用一把更大的锁

在synchronized中我们多次提到锁的概念,java也提供了Lock接口,我们可以使用其实现类来自己定义锁,如下:

ReentrantLock

ReentrantLock 是一种可重入锁,它有以下几个特点:

可重入性:线程在获取了锁后,如果第二次进入时没有其他进程请求过锁,则直接进入,不用重复获取。

公平锁和非公平锁:可以选择使用公平锁(内部维护一个线程的请求顺序的结构,按照线程请求顺序依次唤醒进程分配锁,优势是节省cpu资源,线程无需一直内旋强锁,而且可以控制进程访问顺序,在需要顺序要求的需求下可以使用,缺点是性能不够好,维护结构和唤醒线程都需要时间)或非公平锁(由线程内旋抢占锁,优势是性能好,所有线程都在一直等待,抢到锁就执行,缺点是消耗更多的cpu资源,且无顺序状态下容易造成线程饥饿,适合高并发的需求)。

提供了更多的锁定操作:如 tryLock()(尝试获取锁)和 lockInterruptibly()(可中断的获取锁)。

示例代码:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class ReentrantLockExample {
    private final Lock lock = new ReentrantLock();//默认非公平锁,传入true则返回公平锁
    private int sharedResource = 0;

    public void increment() {
        lock.lock();
        try {
            sharedResource++;
        } finally {
            lock.unlock();
        }
    }
}

ReentrantReadWriteLock

ReentrantReadWriteLock 是读写锁的实现,它分为读锁和写锁,其中写锁比较简单,当一个线程进行写操作时,不允许其他线程进行写操作,但可以进行读操作。示例代码如下:

import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReadWriteLockExample {
    private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final ReentrantReadWriteLock.WriteLock writeLock = rwLock.writeLock();
    private int sharedResource = 0;

    public void write(int value) {
        writeLock.lock();
        try {
            sharedResource = value;
        } finally {
            writeLock.unlock();
        }
    }
}

读锁相对来说复杂一些,因为 读锁并不是限制线程的读操作,而是依旧限制写操作,读锁允许多个线程获取,但当有线程在进行读操作,就不允许写锁被获取,也就是说,读锁实际上就是让读操作和写操作一起竞争资源,但读操作互相不竞争资源,所以读锁并不能解决线程安全问题,必须和写锁共同使用,代码如下

import java.util.concurrent.locks.ReentrantReadWriteLock;

public class ReentrantReadWriteLockExample {
    private int sharedData = 0;
    private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();

    // 读操作
    public void read() {
        rwLock.readLock().lock();
        try {
            // 执行数据读取操作
            int data = sharedData;
            System.out.println(Thread.currentThread().getName() + " 读取到的数据: " + data);
            // 模拟读取操作的延迟
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            rwLock.readLock().unlock();
        }
    }

    // 写操作
    public void write(int value) {
        rwLock.writeLock().lock();
        try {
            sharedData = value;
            System.out.println(Thread.currentThread().getName() + " 写入的数据: " + value);
            // 模拟写操作的延迟
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            rwLock.writeLock().unlock();
        }
    }

    public static void main(String[] args) {
        ReentrantReadWriteLockExample example = new ReentrantReadWriteLockExample();

        // 启动多个读线程
        for (int i = 0; i < 3; i++) {
            new Thread(example::read, "读线程-" + i).start();
        }

        // 启动一个写线程
        new Thread(() -> example.write(42), "写线程").start();

        // 启动更多的读线程
        for (int i = 3; i < 6; i++) {
            new Thread(example::read, "读线程-" + i).start();
        }
    }
}

StampedLock

StampedLock依旧提供了读锁和写锁,与ReentrantReadWriteLock不同的是,其读锁和写锁没有可重入性,也就是说同一线程第二次进入,即使中途没有其他线程获取锁,它依旧要重新获取锁(但资料中依旧说它性能更好,不理解),读写锁机制和ReentrantReadWriteLock一样,代码如下

import java.util.concurrent.locks.StampedLock;

public class StampedLockExample {
    private int sharedData = 0;
    private final StampedLock stampedLock = new StampedLock();

    // 读操作
    public void read() {
        long stamp = stampedLock.readLock();
        try {
            // 执行数据读取操作
            int data = sharedData;
            System.out.println(Thread.currentThread().getName() + " 读取到的数据: " + data);
            // 模拟读取操作的延迟
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            stampedLock.unlockRead(stamp);
        }
    }

    // 写操作
    public void write(int value) {
        long stamp = stampedLock.writeLock();
        try {
            sharedData = value;
            System.out.println(Thread.currentThread().getName() + " 写入的数据: " + value);
            // 模拟写操作的延迟
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            stampedLock.unlockWrite(stamp);
        }
    }

    public static void main(String[] args) {
        StampedLockExample example = new StampedLockExample();

        // 启动多个读线程
        for (int i = 0; i < 3; i++) {
            new Thread(example::read, "读线程-" + i).start();
        }

        // 启动一个写线程
        new Thread(() -> example.write(42), "写线程").start();

        // 启动更多的读线程
        for (int i = 3; i < 6; i++) {
            new Thread(example::read, "读线程-" + i).start();
        }
    }
}
}

StampedLock还提供一种乐观锁 **(乐观锁是值对于线程不进行上锁,而是通过记录上一次获取资源时资源的状态,在更新完资源结果赋值时检查资源状态,如果更改了,则证明其他线程比自己先一步更改了,就要重新获取资源重新执行代码,在线程冲突较少的情况下,乐观锁性能很好)**的实现,它提供了一个对比的变量和对比的方法,但是它却不能实现完全无锁的乐观锁(好没用的类),如果它进行的是写操作,那么必须在写操作之前调用tryConvertToWriteLock来判断时间戳是否更改,如果未更改,则变换为写锁,以此实现乐观锁,代码如下(我只是给出一种实现,不一定是最好的办法):

import java.util.concurrent.locks.StampedLock;

public class OptimisticLockExample {
    private int sharedData = 0;
    private final StampedLock stampedLock = new StampedLock();

    // 乐观读操作
    public void optimisticRead() {
        int data;
        long stamp;
        do {
            // 获取乐观读锁的时间戳
            stamp = stampedLock.tryOptimisticRead();
            // 执行读取操作
            data = sharedData;
            // 在读取后验证时间戳是否有效
        } while (!stampedLock.validate(stamp));

        System.out.println(Thread.currentThread().getName() + " 读取到的数据: " + data);
    }

    // 乐观写操作
    public void optimisticWrite(int value) {
        long stamp;
        boolean success;
        do {
            // 获取乐观读锁的时间戳
            stamp = stampedLock.tryOptimisticRead();
            // 执行读取操作
            int currentData = sharedData;
            // 尝试将乐观读锁转换为写锁
            long writeStamp = stampedLock.tryConvertToWriteLock(stamp);
            if (writeStamp != 0L) {
                stamp = writeStamp;
                success = true;
                // 执行写操作
                sharedData = value;
                System.out.println(Thread.currentThread().getName() + " 写入的数据: " + value);
            } else {
                success = false;
            }
        } while (!success);

        // 释放写锁
        stampedLock.unlockWrite(stamp);
    }

    public static void main(String[] args) {
        OptimisticLockExample example = new OptimisticLockExample();

        // 启动多个读线程
        for (int i = 0; i < 3; i++) {
            new Thread(example::optimisticRead, "读线程-" + i).start();
        }

        // 启动一个写线程
        new Thread(() -> example.optimisticWrite(42), "写线程").start();

        // 启动更多的读线程
        for (int i = 3; i < 6; i++) {
            new Thread(example::optimisticRead, "读线程-" + i).start();
        }
    }
}

在了解了线程的各种方法后,我们便可以轻松的了解线程的六个生命周期,或者说是线程的六种状态,在java中通过Thread中的state维护了这六种状态

NEW(新建状态):此时是刚刚被new出来的新线程对象,还没有调用start运行时

RUNNABLE(就绪状态):词不达意,此时线程并不一样是执行中,而是达成执行条件,等待cpu资源中或正在被cpu执行。

BLOCKED(阻塞状态):线程由于等待获取锁,同步代码块进行等待的状态

WAITING(睡眠状态):线程不抢占cpu资源,等待某个条件被唤醒

TIMED_WAITING(限时睡眠状态):通过某些系统调用,或者其他线程执行方法使线程睡眠,等待一段时间被唤醒。

TERMINATED(中止状态):线程执行完毕。

线程池

线程池在涉及大量线程操作时,频繁的创建销毁线程会大量消耗cpu资源,所以提前创建一批一直存活的线程,由线程池管理,然后分配给需要线程的任务,这样不仅可以减少线程创建和销毁的性能消耗,还能控制线程数量,防止资源耗尽的风险。

线程池的创建有很多,不过创建线程池是一个很重要的操作,所以创建线程池的类应该尽可能让我们自定义的东西多一些,而不是实现的东西多一些,所以最终创建线程池的方法只有一个,那就是ThreadPoolExecutor,实现高度自定义线程池。代码示例如下

import java.util.concurrent.*;

public class CustomThreadPoolExample {
    public static void main(String[] args) {
        // 核心线程数为2
        int corePoolSize = 2;
        // 最大线程数为4
        int maximumPoolSize = 4;
        // 空闲线程存活时间为10秒
        long keepAliveTime = 10;
        // 时间单位为秒
        TimeUnit unit = TimeUnit.SECONDS;
        // 使用有界队列,容量为2
        BlockingQueue workQueue = new ArrayBlockingQueue<>(2);
        // 默认的线程工厂
        ThreadFactory threadFactory = Executors.defaultThreadFactory();
        // 拒绝策略
        RejectedExecutionHandler handler = new ThreadPoolExecutor.CallerRunsPolicy();

        // 创建自定义线程池
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue, threadFactory, handler);

        // 提交任务
        for (int i = 0; i < 10; i++) {
            executor.execute(() -> {
                System.out.println(Thread.currentThread().getName() + " is executing");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }

        // 关闭线程池
        executor.shutdown();
    }
}

ThreadPoolExecutor有七个参数

  • corePoolSize:核心线程数,线程池中始终保持存活的线程数。
  • maximumPoolSize:最大线程数,线程池中允许的最大线程数。
  • keepAliveTime:空闲线程存活时间,超过核心线程数的线程在空闲时间超过此值时将被终止。
  • unit:时间单位, keepAliveTime 的时间单位。
  • workQueue:任务队列,当所有核心线程都在忙时,新任务会被放入此队列。
  • threadFactory:线程工厂,用于创建新线程。
  • handler:拒绝策略,当任务太多无法处理时,如何处理新任务。

核心线程数是指在wq(任务队列)没有满的时候允许拥有的最多线程数,当任务队列满的时候会根据最大线程数创建临时线程,而新来的线程任务,没有进到wq中,则会直接进入临时线程(这也会导致后来的进程先执行),而当最大线程也达到时,新的线程则会根据拒绝策略来处理。拒绝策略如下

AbortPolicy:默认拒绝策略,当线程池无法接受新任务时,会抛出 RejectedExecutionException 异常。这是默认的拒绝策略,适用于需要显式处理任务提交失败的情况。

CallerRunsPolicy:调用者运行策略,当线程池无法接受新任务时,会由调用线程(提交任务的线程)来执行任务,也就是说当线程池达到最大线程数后,在进行现成的start调用会变成当前线程的同步方法调用。这种策略提供了一种简单的反馈控制机制,减缓任务提交的速度,适用于需要保证任务执行但对性能要求不高的情况。

DiscardPolicy:丢弃策略,当线程池无法接受新任务时,会直接丢弃任务,不做任何处理。 适用于不重要的任务,允许任务丢失而不会影响系统稳定性。

DiscardOldestPolicy:丢弃最旧的任务策略,当线程池无法接受新任务时,会丢弃等待队列中最旧的任务,并尝试重新提交新任务。适用于需要优先处理新任务的情况,可以避免任务队列中积压太多的旧任务。