Skip to content

多线程

java里面的线程和操作系统的线程一样吗?

Java 底层会调用 pthread_create 来创建线程,所以本质上 java 程序创建的线程,就是和操作系统线程是一样的,是1对1的线程模型。

image-20240725230425385

Java的线程安全在三个方面体现:

原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作,在Java中使用了atomic和synchronized这两个关键字来确保原子性

可见性:一个线程对主内存的修改可以及时地被其他线程看到,在Java中使用了synchronized和volatile这两个关键字确保可见性:

有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序,在Java中使用了happens-before原则来确保有序性(volatile)

保证数据的一致性有哪些方案呢?

事务管理:使用数据库事务来确保一组数据库操作要么全部成功提交,要么全部失败回滚。通过ACID(原子性、一致性、隔离性、持久性)属性,数据库事务可以保证数据的一致性。

锁机制:使用锁来实现对共享资源的互斥访问。在Java 中,可以使用 synchronized 关键字ReentrantLock 或其他锁机制来控制并发访问,从而避免并发操作导致数据不一致。

版本控制:通过乐观锁的方式,在更新数据时记录数据的版本信息,从而避免同时对同一数据进行修改,进而保证数据的一致性。

线程创建的方式哪些

  1. 继承Thread类,重写run方法,创建该类的实例后,通过调用start()方法启动线程

    如果需要访问当前线程,无须使用Thread.currentThread(),而是直接使用this

  2. 实现Runnable接口,重写run方法,创建该类的实例作为Thread类的构造器的参数,通过调用Thread实例的start()方法启动线程

    在这种方式下,可以多个线程共享同个目标对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU代码和数据分开形成清晰的模型,较好地体现了面向对象的思想。必须使用Thread.currentThread()

  3. 实现Callable接口,配合FutureTask

    java.util.concurrent.Callable接口类似于Runnable,但Callable的call方法可以有返回值并且可以抛出异常。要执行Callable任务,需将它包装进一个FutureTask,因为Thread类的构造器只接受Runnable参数而FutureTask实现了Runnable接口

    java
    class MyCallable implements Callable<Integer>{
        @0verride
        public Integer call()throws Exception {
            //线程执行的代码,这里返回一个整型结果
            return 1;
        }
    }
  4. 使用线程池(Executor框架)

    从Java 5开始引入的java.util.concurrent.ExecutorService和相关类提供了线程池的支持,这是一种更高效的线程管理方式,避免了频繁创建和销毁线程的开销。可以通过Executors类的静态方法创建不同类型的线程池。

    java
    class Task implements Runnable{
        @0verridepublic 
        void run(){
            // 线程执行的代码
        }
        
        public static void main(string[]args){
            ExecutorService executor=Executors.newFixedThreadPool(10);//创建固定大小的线程池
            for(inti=0;i<100;i++){
                executor.submit(new Task());//提交任务到线程池执行
            }
            executor.shutdown();//关闭线程池
        }
    }

    采用线程池方式:

    程池增加了程序的复杂度,特别是当涉及线程池参数调整和故障排查时。错误的配置可能导致死锁、资源耗尽等问题,这些问题的诊断和修复可能较为复杂。

    线程池可以重用预先创建的线程,避免了线程创建和销毁的开销,显著提高了程序的性能。对于需要快速响应的并发请求,线程池可以迅速提供线程来处理任务,减少等待时间。

    线程池能够有效控制运行的线程数量,防止因创建过多线程导致的系统资源耗尽(如内存溢出)。通过合理配置线程池大小,可以最大化CPU利用率和系统吞吐量。

如何停止一个线程的运行?

  • 异常法停止:线程调用interrupt()方法后,在线程的run方法中判断当前对象的interrupted()状态,如果是中断状态则抛出异常,达到中断线程的效果。
  • 在沉睡中停止:先将线程sleep,然后调用interrupt标记中断状态,interrupt会将阻塞状态的线程中断会抛出中断异常,达到停止线程的效果
  • stop()暴力停止:线程调用stop()方法会被暴力停止,方法已弃用,该方法会有不好的后果:强制让线程停止有可能使一些请理性的工作得不到完成。
  • 使用return停止线程:调用interrupt标记为中断状态后,在run方法中判断当前线程状态,如果为中断状态则return,能达到停止线程的效果。

调用 interrupt 是如何让线程抛出异常的?

每个线程都有一个与之关联的布尔属性来表示其中断状态,中断状态的初始值为false,当一个线程被其它线程调用Thread.interrupt()方法中断时,会根据实际情况做出响应。

如果该线程正在执行低级别的可中断方法(如Thread.sleep()、Thread.join()或0bject.wait()),则会解除阻塞并抛出InterruptedException异常。

否则Thread.interrupt()设置线程的中断状态,在该被中断的线程中稍后可通过轮询中断状态来决定是否要停止当前正在执行的任务

InterruptedException

通过调用一个线程的 interrupt() 来中断该线程,如果该线程处于阻塞、限期等待或者无限期等待状态,那么就会抛出 InterruptedException,从而提前结束该线程。但是不能中断 I/O 阻塞和 synchronized 锁阻塞。

对于以下代码,在 main() 中启动一个线程之后再中断它,由于线程中调用了 Thread.sleep() 方法,因此会抛出一个 InterruptedException,从而提前结束线程,不执行之后的语句。

java
public class InterruptExample {

    private static class MyThread1 extends Thread {
        @Override
        public void run() {
            try {
                Thread.sleep(2000);
                System.out.println("Thread run");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}


public static void main(String[] args) throws InterruptedException {
    Thread thread1 = new MyThread1();
    thread1.start();
    thread1.interrupt();
    System.out.println("Main run");
}



Main run
java.lang.InterruptedException: sleep interrupted
    at java.lang.Thread.sleep(Native Method)
    at InterruptExample.lambda$main$0(InterruptExample.java:5)
    at InterruptExample$$Lambda$1/713338599.run(Unknown Source)
    at java.lang.Thread.run(Thread.java:745)

interrupted()

如果一个线程的 run() 方法执行一个无限循环,并且没有执行 sleep() 等会抛出 InterruptedException 的操作,那么调用线程的 interrupt() 方法就无法使线程提前结束。

但是调用 interrupt() 方法会设置线程的中断标记,此时调用 interrupted() 方法会返回 true。因此可以在循环体中使用 interrupted() 方法来判断线程是否处于中断状态,从而提前结束线程。

java
public class InterruptExample {

    private static class MyThread2 extends Thread {
        @Override
        public void run() {
            while (!interrupted()) {
                // ..
            }
            System.out.println("Thread end");
        }
    }
}

public static void main(String[] args) throws InterruptedException {
    Thread thread2 = new MyThread2();
    thread2.start();
    thread2.interrupt();
}

Thread end

Executor 的中断操作

调用 Executor 的 shutdown() 方法会等待线程都执行完毕之后再关闭,但是如果调用的是 shutdownNow() 方法,则相当于调用每个线程的 interrupt() 方法。

以下使用 Lambda 创建线程,相当于创建了一个匿名内部线程。

java
public static void main(String[] args) {
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> {
        try {
            Thread.sleep(2000);
            System.out.println("Thread run");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    });
    executorService.shutdownNow();
    System.out.println("Main run");
}
Main run
java.lang.InterruptedException: sleep interrupted
    at java.lang.Thread.sleep(Native Method)
    at ExecutorInterruptExample.lambda$main$0(ExecutorInterruptExample.java:9)
    at ExecutorInterruptExample$$Lambda$1/1160460865.run(Unknown Source)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
    at java.lang.Thread.run(Thread.java:745)

如果只想中断 Executor 中的一个线程,可以通过使用 submit() 方法来提交一个线程,它会返回一个 Future<?> 对象,通过调用该对象的 cancel(true) 方法就可以中断线程。

java
Future<?> future = executorService.submit(() -> {
    // ..
});
future.cancel(true);

Java线程的状态有哪些?

img

状态解释
NEW尚未启动的线程状态,即线程创建,还未调用start方法
RUNNABLE就绪状态(调用start,等待调度)+正在运行
BLOCKED等待监视器锁时,陷入阻塞状态
WAITING等待状态的线程正在等待另一线程执行特定的操作(如notify)
TIMED_WAITING具有指定等待时间的等待状态
TERMINATED线程完成执行,终止状态

blocked和waiting有啥区别

  • 触发条件:线程进入BLOCKED状态通常是因为试图获取一个对象的锁(monitor lock),但该锁已经被另一个线程持有。这通常发生在尝试进入synchronized块或方法时,如果锁已被占用,则线程将被阻塞直到锁可用。线程进入WAITING状态是因为它正在等待另一个线程执行某些操作,例如调用Object.wait()方法、Thread.join()方法或Locksupport.park()方法。在这种状态下,线程将不会消耗CPU资源,并且不会参与锁的竞争
  • 唤醒机制:当一个线程被阻塞等待锁时,一旦锁被释放,线程将有机会重新尝试获取锁。如果锁此时未被其他线程获取,那么线程可以从BLOCKED状态变为RUNNABLE状态。线程在WAITING状态中需要被显式唤醒。例如,如果线程调用了Object.wait(),那么它必须等待另一个线程调用同一对象上的Object.notify()或Object.notifyAll()方法才能被唤醒

waiting 状态下的线程如何进行恢复到 running 状态?

等待的线程被其他线程对象唤醒,notify()和 notifyAll()

如果线程没有获取到锁则会直接进入 waiting状态,其实这种本质上它就是执行了 Locksupport.park()方法进入了waiting状态,那么解锁的时候会执行Locksupport.unpark(Thread),与上面park方法对应,给出许可证,解除等待状态。

wait, notify, notifyAll

java
/**
 * wait,notify,notifyAll
 */
class WaitNotifyExample {
    public synchronized void before() {
        System.out.println("before");
        notifyAll();
    }

    public synchronized void after() {
        try {
            wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("after");
    }
}

调用 wait() 使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其它线程会调用 notify() 或者 notifyAll() 来唤醒挂起的线程。

它们都属于 Object 的一部分,而不属于 Thread。

只能用在同步方法或者同步控制块中使用,否则会在运行时抛出 IllegalMonitorStateExeception。

使用 wait() 挂起期间,线程会释放锁。这是因为,如果没有释放锁,那么其它线程就无法进入对象的同步方法或者同步控制块中,那么就无法执行 notify() 或者 notifyAll() 来唤醒挂起的线程,造成死锁。

  • wait() 是 Object 的方法,而 sleep() 是 Thread 的静态方法;
  • wait() 会释放锁,sleep() 不会。

await() signal() signalAll()

java.util.concurrent 类库中提供了 Condition 类来实现线程之间的协调,可以在 Condition 上调用 await() 方法使线程等待,其它线程调用 signal() 或 signalAll() 方法唤醒等待的线程。相比于 wait() 这种等待方式,await() 可以指定等待的条件,因此更加灵活。

使用 Lock 来获取一个 Condition 对象。

java
Condition condition = lock.newCondition();
try {
    // 等待
    condition.await();
} catch (InterruptedException e) {
    throw new RuntimeException(e);
}
// 唤醒
condition.signalAll();

notify 和 notifyAll 的区别?

同样是唤醒等待的线程,同样最多只有一个线程能获得锁,同样不能控制哪个线程获得锁

notify 选择哪个线程唤醒?

notify在源码的注释中说到notify选择唤醒的线程是任意的,但是依赖于具体实现的jvm。

JVM有很多实现,比较流行的就是hotspot,hotspot对notofy()的实现并不是我们以为的随机唤醒,而是**“先进先出”**的顺序唤醒。

区别在于:

  • notify:唤醒一个线程,其他线程依然处于waiting的等待唤醒状态,如果被唤醒的线程结束时没调用notify,其他线程就永远没人去唤醒,只能等待超时,或者被中断
  • notifyAll:所有线程退出waiting的状态,开始竞争锁,但只有一个线程能抢到,这个线程执行完后,其他线程又会有一个幸运儿脱颖而出得到锁

怎么保证多线程安全?

  • synchronized关键字:可以使用 synchronized 关键字来同步代码块或方法,确保同一时刻只有一个线程可以访问这些代码。对象锁是通过 synchronized 关键字锁定对象的监视器(monitor)来实现的。

  • volatile关键字:volatile 关键字用于变量,确保所有线程看到的是该变量的最新值,而不是可能存储在本地寄存器中的副本。

  • Lock接囗和ReentrantLock类:java.util.concurrent.locks.Lock接口提供了比 synchronized更强大的锁定机制,ReentrantLock是一个实现该接口的例子,提供了更灵活的锁管理和更高的性能。

    java
    private final ReentrantLock lock = new ReentrantLock();
    
    public void someMethod(){
        lock.lock();
        try{
            /*...*/
        }finally{
            lock.unlock();
        }
    }
  • 原子类: java.util.concurrent.atomic提供了原子类,如 AtomicIntegerAtomiclLong 等,这些类提供了原子操作,可以用于更新基本类型的变量而无需额外的同步

    java
    AtomicInteger counter = new AtomicInteger(0);
    
    int newValue = counter.incrementAndGet();

    getAndIncrement类似于i++,而incrementAndGet类似于++i

  • 线程局部变量:ThreadLocal 类可以为每个线程提供独立的变量副本,这样每个线程都拥有自己的变量,消除了竞争条件。

    java
    ThreadLocal<Integer>threadLocalVar = new ThreadLocal<>();
    
    threadLocalVar.set(10);
    int value =threadLocalVar.get();
  • 并发集合:使用 java.util.concurrent 包中的线程安全集合,如 ConcurrentHashMapConcurrentLinkedQueue 等,这些集合内部已经实现了线程安全的逻辑。

  • JUC工具类:使用 java.util.concurrent 包中的一些工具类可以用于控制线程间的同步和协作。例如:SemaphoreCyclicBarrier

Java中有哪些常用的锁,在什么场景下使用?

锁是用于管理多线程并发访问共享资源的关键机制。锁可以确保在任意给定时间内只有一个线程可以访问特定的资源,从而避免数据竞争和不一致性。Java提供了多种锁机制,可以分为以下几类:

  • 内置锁(synchronized):Java中的 synchronized 关键字是内置锁机制的基础,可以用于方法或代码块。当一个线程进入 synchronized 代码块或方法时,它会获取关联对象的锁;当线程离开该代码块或方法时,锁会被释放。如果其他线程尝试获取同一个对象的锁,它们将被阻塞,直到锁被释放。其中syncronized加锁时有无锁、偏向锁、轻量级锁和重量级锁几个级别(锁升级)。偏向锁用于当一个线程进入同步块时,如果没有任何其他线程竞争,就会使用偏向锁,以减少锁的开销。轻量级锁使用线程栈上的数据结构,避免了操作系统级别的锁。重量级锁则涉及操作系统级的互斥锁。
  • ReentrantLock: java.util.concurrent.Locks.ReentrantLock 是一个显式的锁类,提供了比 synchronized 更高级的功能,如可中断的锁等待、定时锁等待、公平锁选项等。 ReentrantLock 使用lock()unlock()方法来获取和释放锁。其中,公平锁按照线程请求锁的顺序来分配锁,保证了锁分配的公平性,但可能增加锁的等待时间。非公平锁不保证锁分配的顺序,可以减少锁的竞争,提高性能,但可能造成某些线程的饥饿
  • 读写锁(ReadWriteLock):java.util.concurrent.locks.ReadWriteLock 接口 定义了一种锁,允许多个读取者同时访问共享资源,但只允许一个写入者。读写锁通常用于读取远多于写入的情况,以提高并发性。
  • 乐观锁和悲观锁:悲观锁(Pessimistic Locking)通常指在访问数据前就锁定资源,假设最坏的情况,即数据很可能被其他线程修改。 synchronized 和 ReentrantLock 都是悲观锁的例子。乐观锁(Optimistic Locking)通常不锁定资源,而是在更新数据时检查数据是否已被其他线程修改。乐观锁常使用版本号或时间戳来实现。
  • 自旋锁:自旋锁是一种锁机制,线程在等待锁时会持续循环检查锁是否可用,而不是放弃CPU并阻塞。通常可以使用CAS来实现。这在锁等待时间很短的情况下可以提高性能,但过度自旋会浪费CPU资源

怎么在实践中用锁的?

  • synchronized:修饰方法或者代码块

  • ReentrantLock:比synchronized更灵活的锁操作,包括尝试锁、可中断锁、定时锁等(lock,unlock)

  • ReadWriteLock(接口):读写锁,读操作不加锁,写操作独占

    java
    private ReadWriteLock lock = new ReentrantReadWriteLock();
    private Lock readLock = lock.readLock();
    private Lock writeLock = lock.writeLock();
    
    public Object readData(){
        readLock.lock();
        try{
            /*..*/
        }finally{
            readLock.unlock();
        }
    }
    
    public void writeData(Object newData){
        writeLock.lock();
        try{
            /*..*/
        }finally{
            writeLock.unlock();
        }
    }

synchronized和ReentrantLock及其应用场景?

synchronized 工作原理(非公平锁,可重入锁)

在应用Sychronized关键字时需要把握如下注意点:

  • 一把锁只能同时被一个线程获取,没有获得锁的线程只能等待;
  • 每个实例都对应有自己的一把锁(this),不同实例之间互不影响;例外:锁对象是*.class以及synchronized修饰的是static方法的时候,所有对象公用同一把锁
  • synchronized修饰的方法,无论方法正常执行完毕还是抛出异常,都会释放锁
  • synchronized修饰普通方法,锁对象默认是this
  • synchronized修饰代码快,可以自定义锁对象是this或者其他锁
  • synchronized修饰静态方法或者指定锁对象为Class对象,就是类锁

synchronized是Java提供的原子性内置锁,这种内置的并且使用者看不到的锁也被称为监视器锁。

使用synchronized之后,会在编译之后在同步的代码块前后加上monitorentermonitorexit字节码指令。依赖操作系统底层互斥锁实现。他的作用主要就是实现原子性操作解决共享变量的内存可见性问题

执行monitorenter指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器+1。此时其他竞争锁的线程则会进入entryList中。

执行monitorexit指令时则会把计数器-1,当计数器值为0时则锁释放,处于entryList中的线程再继续竞争锁。

synchronized是排它锁,当一个线程获得锁之后,其他线程必须等待该线程释放锁后才能获得锁,由于Java中的线程和操作系统原生线程是一一对应的,线程被阻塞或者唤醒时会从用户态切换到内核态这种转换非常消耗性能

从内存语义来说,加锁的过程会清除工作内存中的共享变量,再从主内存读取,而释放锁的过程则是将工作内存中的共享变量写回主内存

如果再深入到源码来说,synchronized实际上有两个队列waitSetentryList

  1. 当多个线程进入同步代码块时,首先进入entryList

  2. 有一个线程获取到monitor锁后,就赋值给当前线程,并且计数器+1

  3. 如果线程调用wait方法,将释放锁,当前线程置为nul,计数器-1,同时进入waitSet等待被唤醒,调用notify或者notifyAll之后又会进入entrylist竞争锁

  4. 如果线程执行完毕,同样释放锁,计数器-1,当前线程置为null

    img

ReentrantLock 工作原理

ReentrantLock的底层实现主要依赖于AbstractQueuedSynchronizer(AQS)这个抽象类。AQS 是一个提供了基本同步机制的框架,其中包括了队列、状态值等。

ReentrantLock在 AQS 的基础上通过内部类 Sync 来实现具体的锁操作。不同的 Sync 子类实现了公平锁和非公平锁的不同逻辑:

  • 可中断性:ReentrantLock 实现了可中断性,这意味着线程在等待锁的过程中,可以被其他线程中断而提前结束等待。在底层,ReentrantLock 使用了与 LockSupport.park()LockSupport.unpark() 相关的机制来实现可中断性。

  • 设置超时时间:ReentrantLock 支持在尝试获取锁时设置超时时间,即等待一定时间后如果还未获得锁,则放弃锁的获取。这是通过内部的 tryAcquireNanos 方法来实现的。

  • 公平锁和非公平锁:在直接创建 ReentrantLock对象时,默认情况下是非公平锁。公平锁是按照线程等待的顺序来获取锁,而非公平锁则允许多个线程在同一时刻竞争锁,不考虑它们申请锁的顺序。公平锁可以通过在创建 ReentrantLock 时传入 true 来设置,例如: ReentrantLock fairLock = new ReentrantLock(true);

  • 多个条件变量:ReentrantLock 支持多个条件变量,每个条件变量可以与一个 Reentrantlock 关联。这使得线程可以更灵活地进行等待和唤醒操作,而不仅仅是基于对象监视器的 wait() 和 notify()。多个条件变量的实现依赖于 Condition 接口,例如:

    java
    ReentrantLock lock=new ReentrantLock();
    Condition condition=lock.newcondition();// 使用下面方法进行等待和唤醒
    
    condition.await();
    condition.signal();
  • 可重入性:ReentrantLock 支持可重入性,即同一个线程可以多次获得同一把锁,而不会造成死锁。这是通过内部的 holdcount 计数来实现的。当一个线程多次获取锁时,holdcount 递增,释放锁时递减,只有当 holdcount 为零时,其他线程才有机会获取锁。

  • 响应中断不同:ReentrantLock 可以响应中断,解决死锁的问题,而 synchronized 不能响应中断。 底层实现不同:synchronized 是JVM 层面通过监视器实现的,而 ReentrantLock 是基于 AQS 实现的。

synchronized和ReentrantLock的应用场景区别

synchronized:

  • 简单同步需求:当需要对代码块或方法进行简单的同步控制时, synchronized 是一个很好的选择。它使用起来简单,不需要额外的资源管理,因为锁会在方法退出或代码块执行完毕后自动释放。
  • 代码块同步:如果想对特定代码段进行同步,而不是整个方法,可以使用 synchronized 代码块。这可以让你更精细地控制同步的范围,从而减少锁的持有时间,提高并发性能。
  • 内置锁的使用:synchronized 关键字使用对象的内置锁(也称为监视器锁)这在需要使用对象作为锁对象的情况下很有用,尤其是在对象状态与锁保护的代码紧密相关时。

ReentrantLock:

  • 高级锁功能需求:ReentrantLock 提供了 synchronized 所不具备的高级功能,如公平锁、响应中断定时锁尝试、以及多个条件变量。当你需要这些功能时,ReentrantLock 是更好的选择。
  • 性能优化:在高度竞争的环境中,Reentrantlock 可以提供比 synchronized 更好的性能,因为它提供了更细粒度的控制,如尝试锁定和定时锁定,可以减少线程阻塞的可能性。
  • 复杂同步结构:当你需要更复杂的同步结构,如需要多个条件变量来协调线程之间的通信时,ReentrantLock 及其配套的 condition 对象可以提供更灵活的解决方案。

综上,synchronized 适用于简单同步需求和不需要额外锁功能的场景,而 ReentrantLock 适用于需要更高级锁功能、性能优化或复杂同步逻辑的情况。选择哪种同步机制取决于具体的应用需求和性能考虑。

ReentrantLock 和 synchronized 实现可重入锁的区别

ReentrantLock

ReentrantLock实现可重入锁的机制是基于线程持有锁的计数器。

当一个线程第一次获取锁时,计数器会加1,表示该线程持有了锁。在此之后,如果同一个线程再次获取锁,计数器会再次加1。每次线程成功获取锁时,都会将计数器加1。当线程释放锁时,计数器会相应地减1。只有当计数器减到0时,锁才会完全释放,其他线程才有机会获取锁。

synchronized

synchronized底层是利用计算机系统mutex Lock实现的。每一个可重入锁都会关联一个线程!D和一个锁状态status

当一个线程请求方法时,会去检查锁状态

  1. 如果锁状态是0,代表该锁没有被占用,使用CAS操作获取锁,将线程ID替换成自己的线程ID。
  2. 如果锁状态不是0,代表有线程在访问该方法。此时,如果线程!D是自己的线程ID,如果是可重入锁,会将status自增1,然后获取到该锁,进而执行相应的方法;如果是非重入锁,就会进入阻塞队列等待。

在释放锁时

  1. 如果是可重入锁的,每一次退出方法,就会将status减1直至status的值为0,最后释放该锁
  2. 如果非可重入锁的,线程退出方法,直接就会释放该锁。

Monitorenter和Monitorexit

MonitorenterMonitorexit指令,会让对象在执行,使其锁计数器加1或者减1。每一个对象在同一时间只与一个monitor(锁)相关联,而一个monitor在同一时间只能被一个线程获得,一个对象在尝试获得与这个对象相关联的Monitor锁的所有权的时候,monitorenter指令会发生如下3中情况之一:

  • monitor计数器为0,意味着目前还没有被获得,那这个线程就会立刻获得然后把锁计数器+1,一旦+1,别的线程再想获取,就需要等待
  • 如果这个monitor已经拿到了这个锁的所有权,又重入了这把锁,那锁计数器就会累加,变成2,并且随着重入的次数,会一直累加
  • 这把锁已经被别的线程获取了,等待锁释放

monitorexit指令:释放对于monitor的所有权,释放过程很简单,就是讲monitor的计数器减1,如果减完以后,计数器不是0,则代表刚才是重入进来的,当前线程还继续持有这把锁的所有权,如果计数器变成0,则代表当前线程不再拥有该monitor的所有权,即释放锁。

下图表现了对象,对象监视器,同步队列以及执行线程状态之间的关系:

img

该图可以看出,任意线程对Object的访问,首先要获得Object的监视器,如果获取失败,该线程就进入同步状态,线程状态变为BLOCKED,当Object的监视器占有者释放后,在同步队列中得线程就会有机会重新获取该监视器。

可重入原理:加锁次数计数器

  • 什么是可重入?可重入锁

可重入:(来源于维基百科)若一个程序或子程序可以“在任意时刻被中断然后操作系统调度执行另外一段代码,这段代码又调用了该子程序不会出错”,则称其为可重入(reentrant或re-entrant)的。即当该子程序正在运行时,执行线程可以再次进入并执行它,仍然获得符合设计时预期的结果。与多线程并发执行的线程安全不同,可重入强调对单个线程执行时重新进入同一个子程序仍然是安全的。

可重入锁:又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。

保证可见性的原理:内存模型和happens-before规则

Synchronized的happens-before规则,即监视器锁规则:对同一个监视器的解锁,happens-before于对该监视器的加锁(就是对监视器解锁的操作,先行发生于对这个监视器锁的加锁操作)

根据happens-before的定义中的一条:如果A happens-before B,则A的执行结果对B可见,并且A的执行顺序先于B

所以A线程执行完将要释放锁的时候,它对共享变量的操作对于即将要获取到监视器锁的B线程是可见的

JVM中锁的优化

简单来说在JVM中monitorenter和monitorexit字节码依赖于底层的操作系统的Mutex Lock来实现的,但是由于使用Mutex Lock需要将当前线程挂起并从用户态切换到内核态来执行,这种切换的代价是非常昂贵的;然而在现实中的大部分情况下,同步方法是运行在单线程环境(无锁竞争环境)如果每次都调用Mutex Lock那么将严重的影响程序的性能。不过在jdk1.6中对锁的实现引入了大量的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销

  • 锁粗化(Lock Coarsening):也就是减少不必要的紧连在一起的unlock,lock操作,将多个连续的锁扩展成一个范围更大的锁。
  • 锁消除(Lock Elimination):通过运行时JIT编译器的逃逸分析来消除一些没有在当前同步块以外被其他线程共享的数据的锁保护,通过逃逸分析也可以在线程本的Stack上进行对象空间的分配(同时还可以减少Heap上的垃圾收集开销)。
  • 轻量级锁(Lightweight Locking):这种锁实现的背后基于这样一种假设,即在真实的情况下我们程序中的大部分同步代码一般都处于无锁竞争状态(即单线程执行环境),在无锁竞争的情况下完全可以避免调用操作系统层面的重量级互斥锁,取而代之的是在monitorenter和monitorexit中只需要依靠一条CAS原子指令就可以完成锁的获取及释放。当存在锁竞争的情况下,执行CAS指令失败的线程将调用操作系统互斥锁进入到阻塞状态,当锁被释放的时候被唤醒(具体处理步骤下面详细讨论)。
  • 偏向锁(Biased Locking):是为了在无锁竞争的情况下避免在锁获取过程中执行不必要的CAS原子指令,因为CAS原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟。
  • 适应性自旋(Adaptive Spinning):当线程在获取轻量级锁的过程中执行CAS操作失败时,在进入与monitor相关联的操作系统重量级锁(mutex semaphore)前会进入忙等待(Spinning)然后再次尝试,当尝试一定的次数后如果仍然没有成功则调用与该monitor关联的semaphore(即互斥锁)进入到阻塞状态。

java的锁🔒

img

syncronized锁升级的过程讲一下

具体的锁升级的过程是:无锁->偏向锁->轻量级锁->重量级锁。不可逆的

  • 无锁:这是没有开启偏向锁的时候的状态,在JDK1.6之后偏向锁是默认开启的,但是有一个偏向延迟需要在JVM启动之后的多少秒之后才能开启,这个可以通过JVM参数进行设置,同时是否开启偏向锁也可以通过JVM参数设置。
  • 偏向锁:这个是在偏向锁开启之后的锁的状态,如果还没有一个线程拿到这个锁的话,这个状态叫做匿名偏向,当一个线程拿到偏向锁的时候,下次想要竞争锁只需要拿线程ID跟MarkWord当中存储的线程ID进行比较,如果线程ID相同则直接获取锁(相当于锁偏向于这个线程),不需要进行CAS操作和将线程挂起的操作
  • 轻量级锁:在这个状态下线程主要是通过CAS操作实现的。将对象的MarkWord存储到线程的虚拟机栈上,然后通过CAS将对象的MarkWord的内容设置为指向Displaced Mark Word的指针,如果设置成功则获取锁。在线程出临界区的时候,也需要使用CAS,如果使用CAS替换成功则同步成功,如果失败表示有其他线程在获取锁,那么就需要在释放锁之后将被挂起的线程唤醒。(注意,这个CAS操作预期值就是预期中上次获取锁的线程ID)
  • 重量级锁:当有两个以上的线程获取锁的时候轻量级锁就会升级为重量级锁,因为CAS如果没有成功的话始终都在自旋,进行while循环操作,这是非常消耗CPU的,但是在升级为重量级锁之后,线程会被操作系统调度然后挂起,这可以节约CPU资源。

image.png

线程A进入 synchronized 开始抢锁,JVM 会判断当前是否是偏向锁的状态,如果是就会根据 Mark Word中存储的线程 ID 来判断,当前线程A是否就是持有偏向锁的线程。如果是,则忽略 check,线程A直接执行临界区内的代码。

但如果 Mark Word 里的线程不是线程 A,就会通过自旋尝试获取锁,如果获取到了,就将 Mark Word 中的线程 ID 改为自己的;如果竞争失败,就会立马撤销偏向锁,膨胀为轻量级锁。

后续的竞争线程都会通过自旋来尝试获取锁,如果自旋成功那么锁的状态仍然是轻量级锁。然而如果竞争失败,锁会膨胀为重量级锁,后续等待的竞争的线程都会被阻塞。

JVM对synchornized的优化?

synchronized 核心优化方案主要包含以下4个:

  • 锁膨胀:synchronized 从无锁升级到偏向锁,再到轻量级锁,最后到重量级锁的过程,它叫做锁膨胀也叫做锁升级。JDK 1.6之前,synchronized 是重量级锁,也就是说 synchronized 在释放和获取锁时都会从用户态转换成内核态,而转换的效率是比较低的。但有了锁膨胀机制之后,synchronized 的状态就多了无锁、偏向锁以及轻量级锁了,这时候在进行并发操作时,大部分的场景都不需要用户态到内核态的转换了,这样就大幅的提升了synchronized 的性能。
  • 锁消除:指的是在某些情况下,JVM 虚拟机如果检测不到某段代码被共享和竞争的可能性,就会将这段代码所属的同步锁消除掉,从而提高程序性能。
  • 锁粗化:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
  • 自适应自旋锁:指通过自身循环,尝试获取锁的一种方式,优点在于它避免一些线程的挂起和恢复操作,因为挂起线程和恢复线程都雲要从用户态转入内核态,这个过程是比较慢的,所以通过自旋的方式可以一定程度上避免线程挂起和恢复所造成的性能开销。

synchronized的同步机制

就看synchronized关键字同步的是谁

  • 同步一个代码快,作用于同一个对象(如果是两个对象,那么就不会同步)
  • 同步一个方法,和上面一样,作用于同一个对象
  • 同步一个类,作用于整个类
  • 同步一个静态方法,作用于整个类

看看下面的例子即可

java
// 同步代码块
public class SynchronizedExample {

    public void func1() {
        synchronized (this) {
            for (int i = 0; i < 10; i++) {
                System.out.print(i + " ");
            }
        }
    }
}
public static void main(String[] args) {
    SynchronizedExample e1 = new SynchronizedExample();
    SynchronizedExample e2 = new SynchronizedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> e1.func1());
    executorService.execute(() -> e1.func1());
    // 锁住了同一个对象,上面两行会同步,下面这个不是同一个对象,不会同步
    executorService.execute(() -> e2.func1());
}
------
// 同步方法
public class SynchronizedExample {

    public synchronized void func1() {
        for (int i = 0; i < 10; i++) {
            System.out.print(i + " ");
        }
    }
}
// 锁住了同一个对象,上面两行会同步,下面这个不是同一个对象,不会同步
------

// 同步一个类,以及同步静态方法
public class SynchronizedExample {

    public void func1() {
        synchronized (SynchronizedExample.class) {
            for (int i = 0; i < 10; i++) {
                System.out.print(i + " ");
            }
        }
    }
}
// 只要是这个类的实例,都是同步的

自旋锁和自适应自旋锁

自旋锁

自旋锁早在JDK1.4 中就引入了,只是当时默认时关闭的。在JDK 1.6后默认为开启状态。自旋锁本质上与阻塞并不相同,先不考虑其对多处理器的要求,如果锁占用的时间非常的短,那么自旋锁的性能会非常的好,相反,其会带来更多的性能开销(因为在线程自旋时,始终会占用CPU的时间片,如果锁占用的时间太长,那么自旋的线程会白白消耗掉CPU资源)。因此自旋等待的时间必须要有一定的限度,如果自旋超过了限定的次数仍然没有成功获取到锁,就应该使用传统的方式去挂起线程了,在JDK定义中,自旋锁默认的自旋次数为10次,用户可以使用参数-XX:PreBlockSpin来更改。

可是现在又出现了一个问题:如果线程锁在线程自旋刚结束就释放掉了锁,那么是不是有点得不偿失。所以这时候我们需要更加聪明的锁来实现更加灵活的自旋。来提高并发的性能。(这里则需要自适应自旋锁!)

自适应自旋锁

在JDK 1.6中引入了自适应自旋锁。这就意味着自旋的时间不再固定了,而是由前一次在同一个锁上的自旋 时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获取过锁,并且持有锁的线程正在运行中,那么JVM会认为该锁自旋获取到锁的可能性很大,会自动增加等待时间。比如增加到100此循环。相反,如果对于某个锁,自旋很少成功获取锁。那再以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,JVM对程序的锁的状态预测会越来越准确,JVM也会越来越聪明。

介绍AQS

AQS全称为AbstractQueuedSynchronizer,是Java中的一个抽象类。 AQS是一个用于构建锁、同步器、协作工具类的工具类(框架)

AQS核心思想是,如果被请求的共享资源空闲,那么就将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定状态;如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中。

AQS完成的主要任务是:同步状态(比如说计数器)的原子性管理;线程的阻塞和解除阻塞;队列的管理。

CLH:Craig、Landin and Hagersten队列,是单向链表,AQS中的队列是CLH变体的虚拟双向队列(FIFO),AQS是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配

image.png

AOS使用一个Volatile的int类型的成员变量来表示同步状态,通过内置的FFO队列来完成资源获取的排队工作,通过CAS完成对State值的修改。

Threadlocal作用,原理,具体里面存的key value是啥,会有什么问题,如何解决?

ThreadLocal 是Java中用于解决线程安全问题的一种机制,它允许创建线程局部变量,即每个线程都有自己独立的变量副本,从而避免了线程间的资源共享和同步问题。

img

  • Thread类中,有个ThreadLocal.ThreadLocalMap的成员变量。
  • ThreadLocalMap内部维护了Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal本身,value是ThreadLocal的泛型对象值

ThreadLocal的作用

  • 线程隔离:ThreadLocal 为每个线程提供了独立的变量副本,这意味着线程之间不会相互影响,可以安全地在多线程环境中使用这些变量而不必担心数据竞争或同步问题
  • 降低耦合度:在同一个线程内的多个函数或组件之间,使用 ThreadLocal 可以减少参数的传递,降低代码之间的耦合度,使代码更加清晰和模块化。
  • 性能优势:由于 ThreadLocal 避免了线程间的同步开销,所以在大量线程并发执行时,相比传统的锁机制,它可以提供更好的性能。

ThreadLocal的原理

ThreadLocal 的实现依赖于 Thread 类中的一个 ThreadlocalMap 字段,这是一个存储 ThreadLocal 变量本身和对应值的映射。每个线程都有自己的 ThreadLocalMap 实例,用于存储该线程所持有的所有 ThreadLocal 变量的值。

当你创建一个 ThreadLocal 变量时,它实际上就是一个 ThreadLocal 对象的实例。每个 ThreadLocal 对象都可以存储任意类型的值,这个值对每个线程来说是独立的。

当调用 ThreadLocal 的 get()方法时, ThreadLocal 会检査当前线程的 ThreadLocalMap 中是否有与之关联的值;如果有,返回该值;如果没有,会调用 initialvalue()方法(如果重写了的话)来初始化该值,然后将其放入 ThreadLocalMap 中并返回。

当调用 set()方法时,ThreadLocal 会将给定的值与当前线程关联起来,即在当前线程的ThreadLocalMap存储一个键值对,键是 ThreadLocal 对象自身,值是传入的值。

当调用 remove()方法时,会从当前线程的 ThreadLocalMap 中移除与该 ThreadLocal 对象关联的条目。

可能存在的问题

当一个线程结束时,其 ThreadLocalMap 也会随之销毁,但是 ThreadLocal 对象本身不会立即被垃圾回收,直到没有其他引用指向它为止。(这就是因为ThreadLocalMapThread类的属性)

因此,在使用 ThreadLocal 时需要注意,如果不显式调用 remove()方法,或者线程结束时未正确清理ThreadLocal 变量,可能会导致内存泄漏,因为 ThreadLocalMap 会持续持有 ThreadLocal 变量的引用,即使这些变量不再被其他地方引用。

WARNING

这里强调一下内存泄露和内存溢出理解上的区别

  • 内存泄漏:本该被清理的对象没有被清理,一直在内存中占用导致内存空间不够
  • 内存溢出:已有的对象占有了太多内存空间,不够分配

因此,实际应用中需要在使用完 ThreadLocal 变量后调用 remove()方法释放资源

悲观锁和乐观锁的区别?

乐观锁:对于并发间操作产生的线程安全问题持乐观状态,乐观锁认为竞争不总是会发生,因此它不需要持有锁,将比较-替换这两个动作作为一个原子操作尝试去修改内存中的变量,如果失败则表示发生冲突,那么就应该有相应的重试逻辑。(比如自旋)

悲观锁:对于并发间操作产生的线程安全问题持悲观状态,悲观锁认为竞争总是会发生,因此每次对某资源进行操作时,都会持有一个独占的锁,就像 synchronized,直接上了锁就操作资源

Java中想实现一个乐观锁,都有哪些方式?

  1. CAS(Compare and swap)操作:CAS 是乐观锁的基础。Java 提供了 java.util.concurrent.atomic,包含各种原子变量类(如 AtomicInteger、AtomicLong),这些类使用 CAS 操作实现了线程安全的原子操作,可以用来实现乐观锁。
  2. 版本号控制:增加一个版本号字段记录数据更新时候的版本,每次更新时递增版本号。在更新数据时,同时比较版本号,若当前版本号和更新前获取的版本号一致,则更新成功,否则失败。
  3. 时间戳:使用时间戳记录数据的更新时间,在更新数据时,在比较时间戳。如果当前时间戳大于数据的时间戳,则说明数据已经被其他线程更新,更新失败。

CAS 有什么缺点?

  • ABA问题:ABA的问题指的是在CAS更新的过程中,当读取到的值是A,然后准备赋值的时候仍然是A,但是实际上有可能A的值被改成了B,然后又被改回了A,这个CAS更新的漏洞就叫做ABA。只是ABA的问题大部分场景下都不影响并发的最终效果

    Java中有AtomicStampedReference来解决这个问题,他加入了预期标志和更新后标志两个字段,更新时不光检查值,还要检査当前的标志是否等于预期标志,全部相等的话才会更新

  • 循环时间长开销大:自旋CAS的方式如果长时间不成功,会给CPU带来很大的开销。

  • 只能保证一个共享变量的原子操作:只对一个共享变量操作可以保证原子性,但是多个则不行,多个可以通过AtomicReference来处理或者使用锁synchronized实现

为什么不能所有的锁都用CAS?

CAS操作是基于循环重试的机制,如果CAS操作一直末能成功,线程会一直自旋重试,占用CPU资源。在高并发情况下,大量线程自旋会导致CPU资源浪费。

volatile 的作用(不保证线程安全)

  • 保证变量对所有线程的可见性。当一个变量被声明为volatile时,它会保证对这个变量的写操作会立即刷新到主存中,而对这个变量的读操作会直接从主存中读取,从而确保了多线程环境下对该变量访问的可见性

    这意味着某个线程修改了volatie变量的值,其他线程能够立刻看到这个修改,不会受到各自线程工作内存的影响。

  • 禁止指令重排序优化。volatile头键字在Java中主要通过内存屏障来禁止特定类型的指令重排序。

    1. 写-写(write-Write)屏障:在对volatile变量执行写操作之前,会插入一个写屏障。这确保了在该变量写操作之前的所有普通写操作都已完成,防止了这些写操作被移到volatile写操作之后。
    2. 读-写(Read-write)屏障:在对volatile变量执行读操作之后,会插入一个读屏障。它确保了对volatile变量的读操作之后的所有普通读操作都不会被提前到volatile读之前执行,保证了读取到的数据是最新的。
    3. 写-读(Write-Read)屏障:这是最重要的一个屏障,它发生在volatile写之后和volatile读之前。这个屏障确保了volatile写操作之前的所有内存操作(包括写操作)都不会被重排序到volatile读之后,同时也确保了volatile读操作之后的所有内存操作(包括读操作)都不会被重排序到volatile写之前。

Volatile的处理

  1. 将当前处理器缓存行的数据写回到系统内存。

  2. 写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2 或其他)后再进行操作,但操作完不知道何时会写到内存。

如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送一条 lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。

为了保证各个处理器的缓存是一致的,实现了缓存一致性协议(MESI),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

所有多核处理器下还会完成:当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。

volatile 变量通过这样的机制就使得每个线程都能获得该变量的最新值。

缓存一致性

缓存本身是独立的,但是内存是共享资源,所有的内存访问都要经过仲裁(同一个指令周期中,只有一个 CPU 缓存可以读写内存)。 CPU 缓存不仅仅在做内存传输的时候才与总线打交道,而是不停在嗅探总线上发生的数据交换,跟踪其他缓存在做什么。 当一个缓存代表它所属的处理器去读写内存时,其它处理器都会得到通知,它们以此来使自己的缓存保持同步。 只要某个处理器写内存,其它处理器马上知道这块内存在它们的缓存段中已经失效。

指令重排序的原理是什么?

在执行程序时,为了提高性能,处理器和编译器常常会对指令进行重排序,但是重排序要满足下面2个条件才能进行:

  • 在单线程环境下不能改变程序运行的结果
  • 存在数据依赖关系的不允许重排序。

所以重排序不会对单线程有影响,只会破坏多线程的执行语义

java
double width= 15.67;
double height = 14.32;
double area =width *height;

我们看这个例子,“1”和“3”之间存在数据依赖关系,同时“2”和“3”之间也存在数据依赖关系。因此在最终执行的指令席列中,“3”不能被重排序到“1”和“2”的前面,如果“3”排到“1”和“2”的前面,那么程序的结果将会被改变。但“1”和“2”之间没有数据依赖关系,编译器和处理器可以重排序“1”和“2”之间的执行顺序。

什么是公平锁和非公平锁?

公平锁:指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点在于各个线程公平平等,每个线程等待一段时间后,都有执行的机会,而它的缺点就在于整体执行速度更慢,吞吐量更小。

非公平锁:多个线程加锁时直接尝试获取锁,能抢到锁到就占有锁,抢不到就到等待队列的队尾等待。非公平锁的优势就在于整体执行速度更快,吞吐量更大,但同时也可能产生线程饥饿问题,也就是说如果一直有线程插队,那么在等待队列中的线程可能长时间得不到运行。

非公平锁吞吐量为什么比公平锁大?

公平锁执行流程:获取锁时,先将线程自己添加到等待队列的队尾并休眠,当某线程用完锁之后,会去唤醒等待队列中队首的线程尝试去获取锁,锁的使用顺序也就是队列中的先后顺序,在整个过程中,线程会从运行状态切换到休眠状态,再从休眠状态恢复成运行状态,但线程每次休眠和恢复都需要从用户态转换成内核态,而这个状态的转换是比较慢的,所以公平锁的执行速度会比较慢。

非公平锁执行流程:当线程获取锁时,会先通过 CAS 尝试获取锁,如果获取成功就直接拥有锁,如果获取锁失败才会进入等待队列,等待下次尝试获取锁。这样做的好处是,获取锁不用遵循先到先得的规则,从而避免了线程休眠和恢复的操作,这样就加速了程序的执行效率。

RetrantLock是怎么实现公平锁的

公平锁获取锁依赖的方法名为tryAcquire,非公平锁获取锁依赖的方法名为nonfairTryAcquire,两者唯一的区别在于公平锁在获取锁时多了一个限制条件hasQueuedPredcessors()==false,也就是判断此时等待队列中是否已经有线程在排队,一旦有线程在排队当前线程就不再尝试获取锁;如果是非公平锁则无论等待队列是否排队都会先尝试获取一下锁,获取不到的时候才去排队。

一个特例:针对于tryLock()方法,不遵守设定的公平原则,因为它调用的直接就是nonfairTryAcquire方法,也就是说即使设置了公平锁的模式,利用tryLock()也可以在有等待线程的时候获取到锁。

线程池

七大参数

  • corePoolsize:线程池核心线程数量。默认情况下,线程池中线程的数量如果<=corePoolSize,那么即使这些线程处于空闲状态,那也不会被销毁。

    核心线程数也是可以设置为0的,此时新的任务来到的时候,会将任务添加到阻塞队列,同时也会判断当前工作线程数是否为0,如果是0则会创建线程执行任务

  • maximumPoolsize:线程池中最多可容纳的线程数量。当一个新任务交给线程池,如果此时线程池中有空闲的线程,就会直接执行,如果没有空闲的线程且当前线程池的线程数量小于corePoolSize,就会创建新的线程来执行任务,否则就会将该任务加入到阻塞队列中,如果阻塞队列满了,就会创建一个新线程,从阻塞队列头部取出一个任务来执行,并将新任务加入到阻塞队列末尾。如果当前线程池中线程的数量等于maximumPoolSize,就不会创建新线程,就会去执行拒绝策略。

    简单说就是,尽可能地只用核心线程数个活跃的线程去执行任务,接下来的线程先去阻塞队列中排队(这个过程中如果核心线程执行完了也是能从队列头部获取然后执行,新的任务继续进入队尾,所以后面的叙述就是需要执行的任务已经挺多了),等到阻塞队列也满了的时候,才会创建新的线程,去协助核心线程处理阻塞队列中的任务。maximumPoolsize的意义就在于某个时刻线程池中最多能有多少个线程,如果已经达到了就不能再创建新线程。

  • keepAliveTime:当线程池中线程的数量大于corePoolsize,并且某个线程的空闲时间超过了keepAliveTime,那 么这个线程就会被销毁

  • unit:就是keepAliveTime时间的单位。

  • workQueue:工作队列(阻塞队列)。当没有空闲的线程执行新任务时,该任务就会被放入工作队列中,等待执行。

  • threadFactory:线程工厂。可以用来给线程取名字等等(可以给由该工厂创建的线程设定优先级,设定是否守护线程,设定命名等等)

  • handler:拒绝策略。如果当前线程池中线程的数量等于maximumPoolsize,就不会创建新线程而是执行拒绝策略。

拒绝策略

常用的四种拒绝策略包括:CallerRunsPolicyAbortPolicyDiscardPolicyDiscardOldestPolicy

此外,还可以通过实现RejectedExecutionHandler接囗来自定义拒绝策略。

  • CallerRunsPolicy使用线程池的调用者所在的线程去执行被拒绝的任务,除非线程池被停止或者线程池的任务队列已有空缺
  • AbortPolicy,直接抛出一个任务被线程池拒绝的异常
  • DiscardPolicy,不做任何处理,静默拒绝提交的任务。
  • DiscardOldestPolicy,抛弃最老的任务,然后执行该任务。
  • 自定义拒绝策略,通过实现接口可以自定义任务拒绝策略

Java的Executors类提供的预制的线程池

  • ScheduledThreadPool:可以设置定期的执行任务,它支持定时或周期性执行任务,比如每隔 10 秒钟执行一次任务,通过这个实现类设置定期执行任务的策略。
  • FixedThreadPool:它的核心线程数和最大线程数一样,所以可以把它看作是固定线程数的线程池,它的特点是线程池中的线程数除了初始阶段需要从0开始增加外,之后的线程数量就是固定的,就算任务数超过线程数,线程池也不会再创建更多的线程来处理任务,而是会把超出线程处理能力的任务放到任务队列中进行等待。而且就算任务队列满了,到了本该继续增加线程数的时候,由于它的最大线程数和核心线程数是一样的,所以也无法再增加新的线程了。
  • CachedThreadPool:可以称作可缓存线程池,它的特点在于线程数是几乎可以无限增加的(实际最大可以达到Integer.MAX_VALUE,为 2^31-1,这个数非常大基本不可能达到),而当线程闲置时还可以对线程进行回收。也就是说该线程池的线程数量不是固定不变的,当然它也有一个用于存储提交任务的队列,但这个队列是SynchronousQueue,队列的容量为0,实际不存储任何任务,它只负责对任务进行中转和传递,所以效率比较高。
  • SingleThreadExecutor:它会使用唯一的线程去执行任务,原理和 FixedThreadPool 是一样的,只不过这里线程只有一个,如果线程在执行任务的过程中发生异常,线程池也会重新创建一个线程来执行后续的任务。这种线程池由于只有一个线程,所以非常适合用于所有任务都需要按被提交的顺序依次执行的场景,而前几种线程池不一定能够保障任务的执行顺序等于被提交的顺序,因为它们是多线程并行执行的。
  • SingleThreadScheduledExecutor:它实际和 ScheduledThreadPool 线程池非常相似,它只是ScheduledThreadPool 的一个特例,内部只有一个线程

线程池一般是怎么用的?

Java 中的 Executors 类定义了一些快捷的工具方法,来帮助我们快速创建线程池。《阿里巴巴 Java 开发手册》中提到,禁止使用这些方法来创建线程池,而应该手动 new ThreadPoolExecutor 来创建线程池。这一条规则的背后,是大量血淋淋的生产事故,最曲型的就是 newfixedThreadPool和 newCachedThreadPool,可能因为资源耗尽导致OOM 问题。所以,不建议使用 Executors 提供的两种快捷的线程池

我们需要根据自己的场景、并发情况评估线程池的几个核心参数,包括核心线程数、最大线程数、线程回收策略、工作队列的类型,以及拒绝策略,确保线程池的工作行为符合需求,一般都需要设置有界的工作队列和可控的线程数。

任何时候,都应该为自定义线程池指定有意义的名称,以方便排査问题。当出现线程数量暴增、线程死锁、线程占用大量 CPU、线程执行出现异常等问题时,我们往往会抓取线程栈。此时,有意义的线程名称,就可以方便我们定位问题。

建议用一些监控手段来观察线程池的状态。线程池这个组件往往会表现得任劳任怨、默默无闻,除非是出现了拒绝策略,否则压力再大都不会抛出一个异常。如果我们能提前观察到线程池队列的积压,或者线程数量的快速膨胀,往往可以提早发现并解决问题。

线程池中shutdown(),shutdownNow()这两个方法有什么作用?

  • shutdown会置状态为SHUTDOWN

    正在执行的任务会继续执行下去,没有被执行的则中断。此时,则不能再往线程池中添加任何任务,否则将会抛出 RejectedExecutionException 异常。

  • 而 shutdownNow会置状态为STOP

    试图停止所有正在执行的线程,不再处理还在池队列中等待的任务,会返回那些未执行的任务。 它试图终止线程的方法是通过调用 Thread.interrupt() 方法来实现的,但是这种方法的作用有限,如果线程中没有sleep、wait、Condition、定时锁等应用,interrupt方法无法中断当前线程。所以ShutdownNow()并不代表线程池就一定立即就能退出,它可能必须要等待所有正在执行的任务都执行完成了才能退出

这里看一下源码

java
public void shutdown() {
    final ReentrantLock mainLock = this.mainLock;
    mainLock.lock();
    try{
        checkShutdownAccess();

        advanceRunState(SHUTDOWN);
        interruptIdleWorkers();
        onShutdown();
    }finally{
        mainLock.unlock();
    }
    tryTeerminate();
}
java
public List<Runnable> shutdownNow() {
    List<Runnable> tasks;
    final ReentrantLock mainLock = this.mainLock;
    mainLock.lock();
    try{
        checkShutdownAccess();

        advanceRunState(STOP);
        interruptWorkers();
        tasks = drainQueue();
    }finally{
        mainLock.unlock();
    }
    tryTeerminate();

    return tasks;
}

Idle意思是“闲置的”,可以看到,在shutdown方法中调用的是interruptIdleWorkers(),表示把闲置的中断,正在执行的继续执行;而shutdownNow方法中调用的是interruptWorkers(),表示尝试把所有任务都中断,而且还会返回闲置的线程

如何优雅的关闭线程池

提交给线程池中的任务可以被撤回吗

可以。当向线程池提交任务时,会得到一个Future对象。这个 Future 对象提供了几种方法来管理任务的执行包括取消任务。

取消任务的主要方法是 Future接口中的 cancel(boolean mayInterruptIfRunning)方法。这个方法尝试取消执行的任务。参数 mayInterruptrIfRunning指示是否允许中断正在执行的任务。如果设置为 true,则表示如果任务已经开始执行,那么允许中断任务;如果设置为 false,任务已经开始执行则不会被中断。

java
public interface Future<V>{
    //是否取消线程的执行
    boolean cancel(boolean mayInterruptIfRunning);
    
    //线程是否被取消
    boolean isCancelled();
    
    //线程是否执行完毕
    boolean isDone();
    
    //立即获得线程返回的结果
    V get() throws InterruptedException,ExecutionException;
    
    //延时时间后再获得线程返回的结果
    V get(long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutException;
}
java
public static void main(string[] args){
    ExecutorService service = Executors.newSingleThreadExecutor();
    Future future =service.submit(new TheradDemo());
    try {
        // 可能抛出异常
        future.get();
    }catch(InterruptedException e){
        e.printstackTrace();
    }catch(ExecutionException e){
        e.printstackTrace();
    }finally {
        //终止任务的执行
        future.cancel(true);
    }
}

多线程控制打印奇偶数的顺序

java
/**
 * 控制线程打印奇偶数v1
 */
class PrintOddEven {

    // 先定义一把锁
    private static final Object lock = new Object();

    // 定义当前打印的数字
    private static int count = 0;

    // 定义最高打印到多少
    private static final int MAX_VALUE = 10;

    /**
     * 定义打印奇数的方法
     */
    public void printOdd() {
        synchronized (lock) {
            while (count <= MAX_VALUE) {
                if (count % 2 == 0) {
                    // 如果是偶数,则应该wait
                    try {
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                } else {
                    // 是奇数
                    System.out.println(Thread.currentThread().getName() + ":" + count++);
                    // 打印完以后就该唤醒
                    lock.notify();
                }
            }
        }
    }

    /**
     * 定义打印偶数的方法
     */
    public void printEven() {
        synchronized (lock) {
            while (count <= MAX_VALUE) {
                if (count % 2 != 0) {
                    // 如果是奇数,则应该wait
                    try {
                        lock.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                } else {
                    // 是偶数
                    System.out.println(Thread.currentThread().getName() + ":" + count++);
                    // 打印完以后就该唤醒
                    lock.notify();
                }
            }
        }
    }
}

--- main
PrintOddEven printOddEven = new PrintOddEven();
new Thread(() -> printOddEven.printOdd(), "odd").start();
new Thread(() -> printOddEven.printEven(), "even").start();

指令重排

上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗? 不一定,为什么呢? 这里可能会发生指令重排序(Instruction Reorder)。

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。从 java 源代码到最终实际执行的指令序列,会分别经历下面三种重排序:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序

  3. 内存系统的重排序。由于处理器使用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些重排序都可能会导致多线程程序出现内存可见性问题。对于编译器,JMM 的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM 的处理器重排序规则会要求 java 编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel 称之为 memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。

单例模式-双重检查锁🔒

java
**
 * 单例模式-双重检查锁
 */
class Singleton {

    /**
     * 使用volatile关键字,禁止指令重排,以及可见性
     */
    private volatile static Singleton instance;

    /**
     * 私有化构造方法,不允许外部随意构建
     */
    private Singleton() {

    }

    /**
     * 设置公共的获取方法
     *
     * @return
     */
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton(); // volatile禁止重排序
                }
            }
        }
        return instance;
    }
}

关键图

悲观锁和乐观锁

img

自旋锁和非自旋锁

img

自旋锁本身是有缺点的,它不能代替阻塞。自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是10次,可以使用-XX:PreBlockSpin来更改)没有成功获得锁,就应当挂起线程。

自旋锁的实现原理同样也是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改数值失败则通过循环来执行自旋,直至修改成功。

公平锁和非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。

非公平锁是多个线程加锁时直接尝试获取锁,获取不到才会到等待队列的队尾等待。但如果此时锁刚好可用,那么这个线程可以无需阻塞直接获取到锁,所以非公平锁有可能出现后申请锁的线程先获取锁的场景。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

img

非公平锁

img

可重入锁和不可冲入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提锁对象得是同一个对象或者class),不会因为之前已经获取过还没释放而阻塞。Java中ReentrantLock和synchronized都是可重入锁,可重入锁的一个优点是可一定程度避免死锁。

首先ReentrantLock和NonReentrantLock都继承父类AQS,其父类AQS中维护了一个同步状态status来计数重入次数,status初始值为0。

当线程尝试获取锁时,可重入锁先尝试获取并更新status值,如果status == 0表示没有其他线程在执行同步代码,则把status置为1,当前线程开始执行。如果status != 0,则判断当前线程是否是获取到这个锁的线程,如果是的话执行status+1,且当前线程可以再次获取锁。而非可重入锁是直接去获取并尝试更新当前status的值,如果status != 0的话会导致其获取锁失败,当前线程阻塞。

下面用示例代码来进行分析:

java
public class Widget {
    public synchronized void doSomething() {
        System.out.println("方法1执行...");
        doOthers();
    }

    public synchronized void doOthers() {
        System.out.println("方法2执行...");
    }
}

img

独享锁(排他锁) VS 共享锁

独享锁和共享锁同样是一种概念。我们先介绍一下具体的概念,然后通过ReentrantLock和ReentrantReadWriteLock的源码来介绍独享锁和共享锁。

独享锁也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。

共享锁是指该锁可被多个线程所持有。如果线程T对数据A加上共享锁后,则其他线程只能对A再加共享锁,不能加排它锁。获得共享锁的线程只能读数据,不能修改数据。

独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

技术漫游

本站访客数 人次 本站总访问量