结合 操作系统、Java多线程 学习并发编程

为什么我们需要考虑并发?不考虑的话会出现什么问题?

并发的多个程序(进程/线程)会对计算机资源进行争夺,如果不加以控制会出现混乱、严重影响程序运行效率,甚至错误

首先是对CPU时间片的争夺

对于多线程编程而言,由于创建线程后,线程的执行顺序是由调度程序控制的,也就是说各个线程的执行顺序并没有一个确定的预期,显然在很多情况下这会影响到我们的编程逻辑,所以我们首先需要一些方法能够实现对线程执行顺序时机的控制

其次,是对共享内存访问(读写)的问题

例如我们使用两个线程访问同一个共享的全局变量对其做大量的自加操作,由于:

  1. 首先自加操作并不是原子的

事实上包含了三条指令

  1. 从内存中读取变量的值
  2. 对变量值加一
  3. 将操作后的变量值写回
  1. 于是,例如thread1在读取count的值比如0后,+1,但是还没写回内存之前,发生了时钟中断thread1让出CPU并将状态保存到 TCB,thread2得到了时间片并开始执行,读取count的值,由于thread1的更改还没有写回内存,于是thread2读到的还是0

那么,最终的结果就是,原本预期是被加了两次结果是2count值,最终得到的却是1
也就是说如果不对并发的内存访问加以控制,那么最终得到的count值和有可能是小于我们的预期的

这其实也是我遇到的一个面试题,面试官希望我举一个例子说明什么时候会出现并发问题

下面是一段简单的示例代码,用来说明上面的两种问题

public class CreateThread {

    static int count = 0;// 一个全局共享变量

    /**
     * 一个线程内部类
     */
    static class MyThread extends Thread {
        @Override
        public void run() {
            for (int i = 0; i < 10000; i++) count++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        MyThread thread1 = new MyThread();
        MyThread thread2 = new MyThread();
        thread1.start();
        thread2.start();
        thread1.join(); // 等待 thread1 执行完成
        thread2.join(); // 等待 thread2 执行完成
        System.out.println(count);
    }
}
讲解一下

首先对于问题1,在程序中的体现在于:
我希望在两个子线程执行结束后,然后再打印最终的count变量,而不是子线程还在执行甚至还没执行就打印了count值,于是我使用了join()方法,它会使得主线程(也就是main线程)在子线程执行结束后再执行

对于问题2,在程序中的体现在于:
两个子线程在执行过程中分别访问并对count变量做了自加操作,各自10000次,我们预期结果应该是20000(当然我们知道这是不对的),但是结果得到了比如:17691、16976、12030

在上面的代码中,执行for (int i = 0; i < 10000; i++) count++;这段代码的多个线程可能导致竞争状态,即临界区——访问共享变量(资源)的代码片段,一定不能由多个线程同时执行

那么我们如何实现原子操作?

另一种说法是:事务

首先我们可以使用锁机制,锁是一种互斥概念,同一把锁同一时间只能被一个线程持有,而其他线程必须等待

锁 本质上是一个对象,并且只有持有这个对象的线程才能够进入临界区并执行代码,同一时间只有一个线程能够获取到唯一的锁对象,于是就保证了临界区的代码是单线程执行的,从而解决了并发问题

Java语言中对锁的支持全面,大致有以下几种

  1. 我们最熟悉的synvhronized关键字
  2. Lock接口
    ReentrantLock
    main()方法不变,我们将代码改成这样:
    private static final ReentrantLock lock = new ReentrantLock();

    static int count = 0;// 一个全局共享变量

    /**
     * 一个线程内部类
     */
    static class MyThread extends Thread {
        @Override
        public void run() {
            for (int i = 0; i < 10000; i++) {
                lock.lock();
                try {
                    count++;
                } finally {
                    lock.unlock();
                }
            }
        }
    }

测试可打断特性

public class ReentrantTest {
    private static final ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args){
        Thread t1 = new Thread(() -> {
            try {
                /*
                如果没有竞争此方法会获取lock对象锁
                有竞争就进入阻塞队列,但是可以被其他线程用interrupt()方法打断
                 */
                lock.lockInterruptibly();
            } catch (InterruptedException e) {
                e.printStackTrace();
                System.out.println("没有获得锁,返回");
                return;
            }
            try {
                System.out.println("获取到锁");
            } finally {
                lock.unlock();
            }
        }, "testThread");

        lock.lock();

        t1.start();
        System.out.println("打断t1");
        t1.interrupt();

    }
}
自旋锁

实现可用自旋锁的关键是:将测试旧锁值和设置新锁值合并为一步原子操作(不然存在中断的话就会出现并发问题)

  1. 自旋锁可以提供互斥支持

  2. 不公平,可能一直自旋并最终饿死

  3. 单核下性能开销相当大

    这里有一个问题:对于单核CPU来说,同一时间只有一个线程抢到了时间片在执行,其他线程连时间片都没有,也就是不执行,谈何“自旋”呢?

    实际上是:这里的“自旋”是指,其他线程即使抢到了时间片,也会一直尝试获取锁,同时我们知道这个锁是被占有的肯定获取不到,那就会导致除了有锁的线程,其他所有线程都至少浪费一个时间片

    多核性能不错(线程数大致等于核心数)

而“自旋锁”的概念对应到Java中便是:1.6之后引入的synchronized锁升级机制

当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程通过自旋的形式尝试获取锁,不会阻塞,提高性能

自旋锁相对于阻塞锁可以提高多线程的性能,主要是因为在竞争不激烈的情况下,自旋锁可以减少线程的上下文切换和调度,避免了进程阻塞和唤醒的开销,从而提高了程序的运行效率。

当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋到一定次数(默认10),还没有获取到锁的时候,就会进入阻塞,该锁膨胀为重量级锁

但是在竞争激烈的情况下,自旋锁可能会造成线程长时间的自旋等待,浪费CPU资源,反而降低了程序的性能。

比较并交换

CAS对应到Java中JUC包,特别是原子类的底层实现,这里暂不做展开

posted @ 2023-05-06 15:01  YaosGHC  阅读(17)  评论(0编辑  收藏  举报