一文搞定面试 | Java并发编程-基础篇(一)

线程的定义和概念

线程是进程中的一个执行单元,每个线程都有独立的执行路径。多个线程可以在同一进程中并发执行,共享进程的资源。线程可以同时执行不同的任务,提高程序的效率。

区别于进程,进程是操作系统中资源分配和调度的基本单位,是程序执行时的实例。每个进程都有独立的内存空间和系统资源。

每个进程都拥有独立的内存空间、文件描述符、打开的文件等系统资源。(线程共享所属进程的内存空间和系统资源)
进程切换时,需要保存和恢复整个进程的上下文信息,包括寄存器、内存映射、打开的文件等,切换开销较大。进程间通信需要使用操作系统提供的机制,如管道、消息队列、共享内存、套接字等。

不同进程之间的执行是并发进行的,每个进程有独立的执行序列。(而线程在同一进程中执行,多个线程之间可以并发执行,共享进程的资源,实现多任务的并发性。)

线程的创建方式

通常情况下,创建线程的方式有以下四种:

  1. 继承Thread类:

    class MyThread extends Thread {
        public void run() {
    
            // 线程的执行逻辑
    
    
        }
    
    }
    
    
    
    
    // 创建线程并启动
    
    MyThread thread = new MyThread();
    thread.start();
    
  2. 实现Runnable接口:

    class MyRunnable implements Runnable {
        public void run() {
    
            // 线程的执行逻辑
    
    
        }
    
    }
    
    
    
    
    // 创建线程并启动
    
    MyRunnable runnable = new MyRunnable();
    Thread thread = new Thread(runnable);
    thread.start();
    
  3. 实现Callable接口:

    class MyCallable implements Callable<Integer> {
        public Integer call() throws Exception {
            // 线程的执行逻辑
    
    
            return 42;
        }
    }
    
     // 创建线程并启动
     Callable<Integer> callable = new MyCallable();
    
     FutureTask<Integer> futureTask = new FutureTask<>(callable);
    
    
     // 创建线程并启动
     Thread thread = new Thread(futureTask);
     thread.start();
    
     try {
         Integer result = futureTask.get();
         System.out.println("任务执行结果:" + result);
     } catch (InterruptedException | ExecutionException e) {
         // 处理异常
     }
    
  4. 线程池创建:

    ThreadPoolExecutor executor = new ThreadPoolExecutor(
        5, // 核心线程数
        10, // 最大线程数
        60, // 线程空闲时间
        TimeUnit.SECONDS, // 空闲时间单位
        new ArrayBlockingQueue<>(100) // 任务队列
    );
    
    
    // 提交任务给线程池
    executor.execute(() -> {
        // 线程的执行逻辑
    });
    
    
    // 关闭线程池
    executor.shutdown();
    

线程池参数配置

  1. 核心线程数(corePoolSize):

    • 线程池中保持活动状态的线程数量,即使线程处于空闲状态也不会被回收。
    • 当有新任务提交时,核心线程会被优先创建和启动。
  2. 最大线程数(maximumPoolSize):

    • 最大线程数是线程池中允许的最大线程数量。
    • 当有新任务提交时,如果核心线程数已满且任务队列已满,会创建新的线程,但不超过最大线程数。
    • 如果线程池中的线程数达到最大线程数,后续的任务会根据拒绝策略进行处理。
  3. 线程空闲时间(keepAliveTime):

    • 线程空闲时间是非核心的空闲线程等待新任务到达的时间。
    • 如果线程在空闲时间内没有接收到新任务,超过空闲时间后会被终止,以减少资源消耗。
    • 可以通过设置合适的空闲时间来控制线程池中线程的数量。
  4. 时间单位(unit):

    • 时间单位是用于表示线程空闲时间和超时时间的单位,例如秒、毫秒等。
  5. 任务队列(workQueue):

    • 任务队列用于存储提交的任务,在线程池中等待执行。
    • 常见的任务队列有:ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue等。
    • 不同的任务队列实现对任务的排队和调度方式有所不同,可以根据具体需求选择合适的任务队列。
  6. 线程工厂(threadFactory):

    • 线程工厂用于创建新的线程。
    • 默认情况下,线程池使用默认的线程工厂创建线程。
    • 可以自定义线程工厂来创建自定义的线程,并指定线程的名称、优先级等属性。
  7. 拒绝策略(rejectedExecutionHandler):

    • 线程池和任务队列都已满,无法继续接收新任务时,拒绝策略定义了如何处理被拒绝的任务。

    1. AbortPolicy(默认):当线程池和任务队列都已满,无法继续接收新任务时,抛出RejectedExecutionException异常,表示拒绝执行新任务。
    2. CallerRunsPolicy:当线程池和任务队列都已满,无法继续接收新任务时,将任务交给提交该任务的线程来执行。这样做的效果是任务提交的线程自己执行任务,而不会抛弃任务。
    3. DiscardPolicy:当线程池和任务队列都已满,无法继续接收新任务时,直接丢弃新任务,不做任何处理。
    4. DiscardOldestPolicy:当线程池和任务队列都已满,无法继续接收新任务时,丢弃任务队列中最旧的任务,然后尝试将新任务加入队列。

线程池配置案例

OkHttp中Dispatcher的线程池配置:

  1. 核心线程数设置为0:如果您的应用场景中有大量的临时性任务,这些任务在某个时间段内可能会有突发的到达,并且在完成后不会再有新的任务到达,那么将核心线程数设置为0可以避免空闲线程的资源浪费。当然因为每次任务到达时都需要创建新的线程,也是有相应的损耗代价的
  2. 最大线程数设置为MAX_VALUE:当然正常不会这么设置,它这里是通过自己有最大请求数去动态控制了
  3. SynchronousQueue作为任务队列:无界的阻塞队列,没有容量使得所有任务都将直接创建新线程(和最大线程数的设置也有关联)。对于插入和移除操作是同步的。当一个线程试图将元素放入队列时,它会被阻塞,直到另一个线程从队列中获取这个元素;反之亦然。这使得任务的提交和执行成为一种同步操作。因为其阻塞特性,当有新的任务提交到线程池时,它会立即寻找一个可用的线程来执行,而不需要将任务先放入队列中等待。这样可以减少任务的等待时间和延迟。
executorServiceOrNull = ThreadPoolExecutor(0, Int.MAX_VALUE, 60, TimeUnit.SECONDS,
    SynchronousQueue(), threadFactory("$okHttpName Dispatcher", false))

死锁

死锁是指在并发系统中,两个或多个线程(或进程)因为互相等待对方释放资源而无法继续执行的状态。在死锁状态下,每个线程都在等待其他线程释放资源,从而导致所有线程都无法继续执行下去。

简单来说:>=2线程,吃着自己碗里的,想着别人碗里的

由此死锁有四个必要条件:

  1. 互斥条件(Mutual Exclusion):一个资源一次只能被一个线程持有。
  2. 不可剥夺条件(No Preemption):已经分配的资源不能被强制性地从线程中抢占。

因为可以抢的话,或者一起用的话,那就不用光想了

所以,可以使用共享资源代替独占资源,或者允许抢占已分配的资源,强制其他线程释放资源(这个就有点流氓了)

  1. 请求与保持条件(Hold and Wait):线程持有至少一个资源,并且在等待其他线程释放资源的同时继续请求新的资源。
  2. 循环等待条件(Circular Wait):存在一个线程资源的循环链,每个线程都在等待下一个线程所持有的资源。

这就是吃着自己碗里的,想着别人碗里的表现

那怎么办?两个思路:

1.排队:一个个按序吃,都必须从1~10,吃了1,才能吃2。即通过对资源进行排序,线程按照相同的顺序请求资源,从而避免循环等待。

2.不贪或者贪死:要么一口气吃完;要么想吃别人的,就把自己的放下。即要么一次性获取所有需要的资源,要么先释放已经持有的资源再重新请求。

上面根据四个条件,给出了预防的策略,那么现实生产过程中,死锁就像ANR,总是可能会发生的。那还有两个方案:

  1. 使用资源分配图算法(Resource Allocation Graph Algorithm)来检测是否存在死锁的可能性。如果检测到可能发生死锁,就不进行资源分配,从而避免死锁的发生。
  2. 使用死锁检测算法(Deadlock Detection Algorithm)来检测死锁的发生。一旦检测到死锁,可以采取一些措施来解除死锁,例如终止某些线程或回滚操作。

对于这两种策略其实都是基于有向图的思路,判断有无环路来进行检测,通常有个算法叫银行家算法,关于死锁的更多这里就不多讲了,水平有限Hhh

线程同步

互斥锁

synchronizedLock是Java中用于实现线程同步的机制。它们都可以用于保证多个线程对共享资源的安全访问,但在实现和使用上有一些不同之处。

  1. synchronized

    • 是Java语言内置的关键字,可以用于代码块、方法、类级别的同步。不需要显式地创建锁对象,会自动释放锁,当持有锁的线程执行完毕或抛出异常时,锁会被释放,其他等待锁的线程可以获取锁并执行。
    • 是非公平的,可重入的
    • 锁升级机制无锁状态(没有线程占有锁)-偏向锁(有线程占有锁)-轻量级锁(发生锁竞争,首先尝试自旋CAS)-重量级锁(竞争失败,开始摆烂),是一种性能上的优化。
  2. Lock

    • 是Java.util.concurrent包下的接口,提供了更加灵活和可扩展的锁机制。
    • 提供了更多的功能,例如可重入性、条件变量、公平性等。
    • 需要显式地进行锁的获取和释放操作,需要在正确的位置手动获取锁,并在合适的时机手动释放锁。通常需要配合try-finally语句块使用,确保锁的释放操作一定会执行,即使在获取锁的过程中发生异常。

AQS

AbstractQueuedSynchronizer AQS 是一个用于实现同步器的抽象基类,提供了底层的同步机制。

public class ReentrantLock implements Lock, Serializable {
    private final Sync sync;



    abstract static class Sync extends AbstractQueuedSynchronizer {
        // 省略其他代码...



        // 尝试获取锁
        abstract void lock();

        // 省略其他代码...
    }


    static final class NonfairSync extends Sync {
        // 省略其他代码...

        // 尝试获取锁
        final void lock() {
            // 调用 AQS 的 acquire 方法尝试获取锁
            if (compareAndSetState(0, 1))
                setExclusiveOwnerThread(Thread.currentThread());
            else
                acquire(1);
        }

        // 省略其他代码...
    }


    static final class FairSync extends Sync {
        // 省略其他代码...

        // 尝试获取锁
        final void lock() {
            acquire(1);
        }

        
        protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            int c = getState();
            if (c == 0) {
                if (!hasQueuedPredecessors() &&
                    compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }
        // 省略其他代码...
    }


    // 省略其他代码...

    // 获取锁
    public void lock() {
        sync.lock();
    }

    // 省略其他代码...
}

Sync 类中定义了 lock() 方法,用于尝试获取锁。在 NonfairSyncFairSync 两个具体子类中,分别实现了不公平锁和公平锁的获取逻辑。这些子类继承了 Sync 并重写了 lock() 方法。

NonfairSync 中,lock() 方法首先尝试使用 compareAndSetState(0, 1) 方法来将锁的状态从 0 设置为 1,如果成功则将当前线程设置为独占锁的持有者;否则,调用 acquire(1) 方法来进入等待状态,直到获取到锁。

其中state是一个原子变量,用于记录锁的重入性,>0则表示占有,在FairSync可以看到,会判断current与当前锁的持有者。而公平性则取决于新的竞争者,是否有机会直接获取锁,还是说会进入等待队列进行按序获取

线程通信

在线程之间实现协调和通信,以便正确地执行任务和共享数据。

  • wait()notify()notifyAll():这些方法是Object类的一部分,用于在线程之间进行等待和唤醒操作。wait()方法使当前线程等待,释放对象的锁,notify()方法唤醒一个等待的线程,notifyAll()方法唤醒所有等待的线程。这些方法应该在synchronized代码块内使用,并且对共享对象调用。

  • 使用Condition接口:Condition接口提供了更高级别的线程通信机制。它与Lock接口一起使用,并提供了await()signal()signalAll()方法来实现等待和唤醒操作。

Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();



// 等待
lock.lock();
try {
    while (condition不满足条件) {
        condition.await();
    }
    // 执行任务
} finally {
    lock.unlock();
}

// 唤醒
lock.lock();
try {
    condition.signalAll();
} finally {
    lock.unlock();
}

通常经典的使用就是生产者-消费者模型,下面给出案例

import java.util.LinkedList;
import java.util.Queue;



class Producer implements Runnable {
    private final Queue<Integer> buffer;
    private final int maxSize;
    private int value = 0;


    public Producer(Queue<Integer> buffer, int maxSize) {
        this.buffer = buffer;
        this.maxSize = maxSize;
    }


    @Override
    public void run() {
        while (true) {
            synchronized (buffer) {
                try {
                    while (buffer.size() == maxSize) {
                        // 队列已满,生产者等待
                        buffer.wait();
                    }
                    // 生产一个元素并加入队列
                    buffer.offer(value);
                    System.out.println("Produced: " + value);
                    value++;


                    // 通知消费者可以消费了
                    buffer.notifyAll();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }

    }
}

class Consumer implements Runnable {
    private final Queue<Integer> buffer;

    public Consumer(Queue<Integer> buffer) {
        this.buffer = buffer;
    }

    @Override
    public void run() {
        while (true) {
            synchronized (buffer) {
                try {
                    while (buffer.isEmpty()) {
                        // 队列为空,消费者等待
                        buffer.wait();
                    }
                    // 从队列中取出一个元素并消费
                    int value = buffer.poll();
                    System.out.println("Consumed: " + value);


                    // 通知生产者可以继续生产
                    buffer.notifyAll();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}


public class ProducerConsumerExample {
    public static void main(String[] args) {
        Queue<Integer> buffer = new LinkedList<>();
        int maxSize = 5;

        Thread producerThread = new Thread(new Producer(buffer, maxSize));
        Thread consumerThread = new Thread(new Consumer(buffer));

        producerThread.start();
        consumerThread.start();
    }
}

原子性和可见性

原子性(Atomicity):原子性指的是一个操作是不可分割的,要么全部执行成功,要么全部不执行,没有中间状态。

原子性需要结合代码指令一起思考:i++

  1. 从内存中读取变量 i 的值到线程的工作内存中。
  2. 在线程的工作内存中对变量 i 的值进行加 1。
  3. 将结果写回内存,更新变量 i 的值。

可见性(Visibility):可见性指的是当一个线程修改了共享变量的值后,其他线程能够立即看到这个修改的结果。

对于可见性,要有一个概念:在多线程环境中,每个线程都有自己的工作内存,线程在执行过程中会将共享变量从主内存中复制到自己的工作内存中进行操作。如果没有适当的同步机制,其他线程可能无法及时看到对共享变量的修改,导致读取到过期的值。

双检索

public class Singleton {
    private volatile static Singleton instance;
    
    private Singleton() {
        // 私有构造函数
    }
    
    public static Singleton getInstance() {
        if (instance == null) { // 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) { // 第二次检查
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

双重检查和锁

一次在同步块外部,一次在同步块内部,来避免多线程环境下重复创建实例的问题。首先,如果instance已经被实例化,那么在同步块外部的检查会直接返回实例。这样可以避免在每次调用getInstance()方法时都进行同步的开销。其次,当多个线程同时通过了第一次检查,进入同步块时,只有一个线程能够进入同步块创建实例,而其他线程在同步块外部等待。

volatile的作用

instance = new Singleton(); 这个操作在Java中实际上可以被分解为以下几个步骤:

  1. 分配内存空间:首先,会在堆内存中为 Singleton 对象分配一块内存空间。

  2. 初始化对象:在分配内存空间后,会对 Singleton 对象进行初始化,即执行构造函数。这一步包括设置对象的成员变量的默认值。

  3. 将对象的引用赋值给变量:在对象初始化完成后,会将对象的引用赋值给 instance 变量。

注意,这些步骤在指令级别上可能会被重新排序,以提高执行效率。而在使用 volatile 关键字修饰的 instance 变量时,会禁止特定类型的指令重排序,确保这些步骤的顺序不会被改变。

由于指令重排序的存在,如果没有使用 volatile 关键字修饰 instance 变量,其他线程可能会在对象初始化之前看到 instance 不为 null,从而可能访问到尚未完全初始化的对象,导致不正确的行为。而使用 volatile 关键字修饰后,可以确保在对象初始化完成之前,不会对 instance 的读写指令进行重排序,从而保证其他线程在读取 instance 变量时能够看到正确的对象状态。当然,其刷新主存同步的作用也是非常重要的

© 版权声明
THE END
喜欢就支持一下吧
点赞0

Warning: mysqli_query(): (HY000/3): Error writing file '/tmp/MYmGBeGe' (Errcode: 28 - No space left on device) in /www/wwwroot/583.cn/wp-includes/class-wpdb.php on line 2345
admin的头像-五八三
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

图形验证码
取消
昵称代码图片