初探:Java 并发原理

619 阅读14分钟

本文使用一问一答的方式,都是我在学习过程中对一些问题的思考与探究,也是一次抛砖引玉,希望各位大佬多多指教。

线程开启

一、创建线程的方式:实现Runnable接口和继承Thread类哪个更好?

答:实现Runnable接口更好

理由有三:

  1. 代码耦合度太高,不利于后续的重构。比如未来如果需要使用Task、Executor等;创建线程、执行线程、销毁线程是Thread类做的事情,我们应该把具体的执行内容解耦出来。
  2. 资源节约。一个Runnable可以使用在多个线程中,可以资源共享;新建线程的损耗,如果继承Thread类,我在使用时必须去new一个线程,这样做的资源损耗是很大的,而使用Runnable的方式,我们可以重复利用同一个线程,线程池就是这样干的
  3. 无法再扩展。java不支持双继承,如果你继承了Thread,你将不能再继承其他的任何类

再看看继承Thread和实现Runnable在代码层面究竟有什么区别?

这里如果你传的Runnable,那么这个run方法就会去调用你在实现Runnable接口时写的run方法。

如果你继承Thread类以后,这里的run方法就会被覆盖,你的执行逻辑随之也与该类绑定。

二、在new Thread()时干了什么?

我们来先看看Thread的构造方法

public Thread() {
        init(null, null, "Thread-" + nextThreadNum(), 0);
}
private void init(ThreadGroup g, Runnable target, String name,
                      long stackSize, AccessControlContext acc,
                      boolean inheritThreadLocals) {
        ......
    }

其中就有对线程名、线程组别、线程栈大小、线程Id、优先级等等的设置,这里的一些属性也就对应了操作系统里面的PCB。

线程停止

三、通常线程会在什么情况下停止?如何去停止?

  1. run方法执行完
  2. 抛出异常未捕获

停止线程只有一种方式

Interrupt。这也只是一种通知的机制,这个线程最终停不停,什么时候停,我们并不能控制,只能由线程本身去控制,它拥有最终解释权。

为什么java要这样设计呢?为什么我还不可以停止我的程序中的线程了?

这是有考虑的,就如我们关闭计算机一样,它也会等待各个程序去自己终止呀,因为只有程序自身才知道它需要做哪些收尾工作。 所以停止线程需要请求方、被停止方和被停止方的子方法相互配合:判断和处理中断标志位,捕获并处理InterruptedException异常

相关知识点

  • sleep过程中如果接收到中断,会抛出异常,如果这个异常被捕获,则它会清除中断标记位
  • interrupted()静态方法会返回当前中断标志位的状态并重置为false,它永远返回当前线程的,而不管是谁调用它(例如在main里面执行threadOne.interrupted()和Thread.interrupted()是一样的
  • 不可中断的阻塞(例如IO)我们该如何处理?很遗憾并没有一个通用的解决方案,只能我们尽量选择可以响应中断的方法

不推荐的停止线程的方式

  • stop会让线程戛然而止,释放所有的监视器
  • suspend会带着锁去休息
  • 使用volatile变量控制是有局限性的,线程在阻塞时将无法响应(例如生产者消费者模式中,阻塞队列满了以后生产者即会阻塞)

线程的生命周期

先看一张图吧~

四、线程状态:什么是阻塞?什么是runnable?

  1. Blocked, Waiting, Timed_waiting都叫阻塞
  2. runnable是指在执行start()方法后的状态,翻译为可运行的,包括等待CPU调度和执行中

线程的相关方法

五、如何手写一个生产者消费者模型?

  1. 使用阻塞队列
  2. 使用wait+notify

阻塞队列的就不演示啦,这里就上wait+notify的


import java.util.LinkedList;

/**
 * 生产者消费者
 *
 * @author heziqi
 */
public class MyProAndCon {

    public static void main(String[] args) {
        Storage<String> storage = new Storage<>();

        Thread pro1 = new Thread(new MyProducer(storage));
        Thread pro2 = new Thread(new MyProducer(storage));
        Thread con = new Thread(new MyConsumer(storage));

        pro1.start();
        pro2.start();
        con.start();
    }

}

class MyProducer implements Runnable{

    private Storage<String> storage;

    public MyProducer(Storage<String> storage) {
        this.storage = storage;
    }

    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            storage.put("test" + i);
        }
    }
}

class MyConsumer implements Runnable{
    private Storage<String> storage;

    public MyConsumer(Storage<String> storage) {
        this.storage = storage;
    }

    @Override
    public void run() {
        for (int i = 0; i < 100; i++) {
            String s = storage.take();
            System.out.println("消费者取到 " + s);
            try {
                Thread.sleep(200);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

class Storage<T> {

    private int maxSize = 10;
    private LinkedList<T> storage;

    public Storage() {
        this.storage = new LinkedList<>();
    }

    public synchronized void put(T t) {
        // 这里为什么不可用用if (storage.size() == maxSize) 或者 if (storage.size() >= maxSize)呢?试一下便知哈哈(notify 的问题)
        while (storage.size() >= maxSize) {
            // 这里必须使用while
            // 不能再放啦,一直在这等着吧
            try {
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        storage.add(t);
        System.out.println(Thread.currentThread().getName() + ": 放入一个元素,目前仓库元素个数 " + storage.size());
        this.notify();
    }

    public synchronized T take() {
        while (storage.size() == 0) {
            // 空啦,拿不出来的,一直在这等着吧
            try {
                this.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        T t = storage.poll();
        System.out.println("取出元素 " + t + " 目前仓库元素个数 " + storage.size());
        this.notify();
        return t;
    }
}

PS:考虑一下Lock+Condition?

六、如何让两个线程交替0-100奇偶数?

  1. 使用synchronized
  2. 使用wait()和notify()

第一种:存在无意义的持有锁

/**
 * 两个线程交替打印0~100的奇偶数,用synchronized关键字实现
 */
public class WaitNotifyPrintOddEvenSyn {

    private static int count;

    private static final Object lock = new Object();

    //新建2个线程
    //1个只处理偶数,第二个只处理奇数
    //用synchronized来通信
    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                while (count < 100) {
                    synchronized (lock) {
                        if ((count & 1) == 0) {
                            System.out.println(Thread.currentThread().getName() + ":" + count++);
                        }
                    }
                }
            }
        }, "偶数").start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                while (count < 100) {
                    synchronized (lock) {
                        if ((count & 1) == 1) {
                            System.out.println(Thread.currentThread().getName() + ":" + count++);
                        }
                    }
                }
            }
        }, "奇数").start();
    }
}

第二种:不用判断,拿到锁即打印

/**
 * 两个线程交替打印0~100的奇偶数,用wait和notify
 */
public class WaitNotifyPrintOddEveWait {

    private static int count = 0;
    private static final Object lock = new Object();

    public static void main(String[] args) {
        new Thread(new TurningRunner(), "偶数").start();
        new Thread(new TurningRunner(), "奇数").start();
    }

    //1. 拿到锁,我们就打印
    //2. 打印完,唤醒其他线程,自己就休眠
    static class TurningRunner implements Runnable {

        @Override
        public void run() {
            while (count <= 100) {
                synchronized (lock) {
                    //拿到锁就打印
                    System.out.println(Thread.currentThread().getName() + ":" + count++);
                    lock.notify();
                    if (count <= 100) {
                        try {
                            //如果任务还没结束,就让出当前的锁,并休眠
                            lock.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        }
    }
}

七、为什么wait方法需要放在同步代码块里面?而sleep不需要?

  1. wait和notify被定义在Object类中,是为了实现线程间的同步。我们先看一看notify和wait方法的注释:

notify:This method should only be called by a thread that is the owner of this object's monitor.

wait:This method should only be called by a thread that is the owner of this object's monitor. See the {@code notify} method for a description of the ways in which a thread can become the owner of a monitor.

 如果线程要调用对象的wait()方法,必须首先获得该对象的监视器锁,调用wait()之后,当前线程又立即释放掉锁,线程随后进入等待池中。如果线程要调用对象的notify()/notifyAll()方法,也必须先获得对象的监视器锁,调用方法之后,立即释放掉锁,然后处于等待池中的线程被转移到等锁池中,去竞争锁资源。

 如果你头巨铁,非要在没获取到锁时去调用wait或者notify,那么恭喜你,你将会获得一个java.lang.IllegalMonitorStateException哈哈

 那我们继续想,为什么java要这么设计呢?意义何在?这就是有名的lost wakeup problem了。描述一下

 消费者          生产者
> size ==0           |
> |                  size+1
> |                  lock.notifyall()
> |                  |
> lock.wait()        |
> |
> :

 我们希望的是满足一定条件后notify去唤醒wait,但是如果没有同步的保证,那就可能notify没有唤醒任何线程。

  1. sleep被定义在Thread类中,只是线程自身的操作,它并不释放锁。

相关知识点

  1. 不建议将thread类作为锁使用,因为线程在执行完成时会自动调用notifyAll方法,具体在os_linux.cpp的java_start方法里
  2. 更优雅的睡眠方式:TimeUnit.SECONDS.sleep()
  3. wait/notify和sleep的异同:
    • 相同:都会阻塞和响应中断
    • 不同:wait/notify得在同步方法中;wait可以指定时间;wait会释放锁,sleep不会;wait/notify属于Object,sleep属于Thread
  4. yield与sleep:yield让出时间片,但是JVM并不能保证遵循yield的意愿,它可能立即被重新调度或者是压根不理会

八、Join是谁在等谁?

答:如果由main去执行thread1.join(),则是main在等thread1。

相关知识点

  1. 如果在主线程等待主线程执行完毕期间遇到其他线程发来的中断请求,则主线程会响应,这个时候视情况将中断传递给子线程。
  2. 可以考虑使用CyclicBarrier和CountDownLatch去解决需要Join的场景。
  3. join其实就是一个wait,那是谁在notify呢?参考上面的为什么不建议将Thread类作为锁呗

九、守护线程和普通线程的区别是什么?我们是否应该设置我们的线程为守护线程?

  1. 是否影响JVM的离开
  2. 一般情况下不应该,守护线程的作用是服务用户线程,他不影响JVM的停止

十、为什么需要UncaughtExceptionHandler?

  1. 主线程可以轻松发现异常,而子线程却不行
  2. 子线程异常无法用传统方法捕获
  3. 不捕获是有后果的,需要提高程序的健壮性

比如这样的的场景:我们线程开多个子线程去下载文件,主线程需要等待子线程下载完成才去执行接下来的操作,那主线程如何知道子线程在下载过程中是否发生异常呢?

线程安全

十一、什么叫线程安全?什么情况下会出现线程安全问题?

  1. 一个对象,如果能够仍由多个线程自由的去访问,并且不需要任何额外的操作,能够像单线程一样的编程后使用多线程运行得到预期的结果,就说明,这个对象是线程安全的。
  2. 线程安全问题:
    • 运行结果错误:a++在多线程下消失的请求
    • 活跃性问题:死锁、活锁、饥饿
    • 对象发布和初始化时候的安全问题
      • 逸出(未初始化完成就已经返回):
        1. 返回了不想被修改的private对象,而这个对象在多个线程中使用,比如map
        2. 初始化操作未执行完成就把this给了外界:构造函数执行过程中将对象抛出、注册监听事件(未初始化完成就已注册)、构造函数中运行线程(我们自己可能不这样写,但是可能调用别人的有涉及线程的操作,例如数据库连接池)

解决逸出:

  1. 副本
  2. 工厂模式

需要考虑线程安全的情况

  1. 访问共享变量或者资源
  2. 依赖时序的操作:read-modify-write, check-then-act
  3. 不同数据之间存在捆绑关系:ip+port,要么一起改,要么都不改
  4. 没有声明自己是线程安全的类:hashMap

十二、为什么多线程会带来性能问题?

  1. 调度:上下文切换
  2. 协作:内存同步

解释

  1. 什么是上下文?寄存器、程序计数器、内存中保存的程序代码的页
  2. 缓存开销:缓存失效(对不同的线程来说)
  3. 何时会导致密集的上下文切换?抢锁、IO
  4. 内存同步:volatile等

并发底层原理

十三、JVM内存结构、java内存模型和java对象模型他们是什么关系?一样吗?

  1. JVM内存结构:与Java虚拟机的运行时区域有关

  2. java内存模型:与java并发编程有关 JMM是一种规范,是并发相关关键字的原理,主要就是重排序、可见性、原子性

    • 重排序:
    • 可见性(java将底层细节抽象为本地内存和主内存)
  3. java对象模型:与java对象在虚拟机中的表现形式有关

十三、happens-before(可见性)规则有哪些?

  1. 单线程规则
  2. 锁操作(synchronize和lock)
  3. volatile变量
  4. 线程启动
  5. 线程join
  6. 传递性
  7. 中断
  8. 构造方法
  9. 工具类的happens-before

解释

  1. 在单线程里,后面执行的语句一定可以知道前面执行的语句干了啥,注意,这里指的是执行。
  2. 后面拿到锁的线程一定知道前面释放锁的线程干了啥
  3. 作用:可见性;禁止重排序(作为触发器,代表之前的代码肯定已经执行完成)。单例模式中双重检测加volatile的原因是防止构造函数重排序。
  4. 子线程启动以后能看到主线程之前干了什么
  5. 线程等待
  6. 第二句能看到第一句,第三句能看到第二句,那第三句肯定可以看到第一句
  7. 一旦中断被执行,就能被看到
  8. 线程安全的容器,get一定能看到在此之前的put

十四、java有哪些原子性的操作?

  1. 除了long, double以外所有基本类型的赋值
  2. 引用的赋值
  3. Atomic包下面的类

并发与死锁

十五、java如何定位死锁?如何修复?实际工程中我们如何做?

  1. 定位死锁:java命令、代码逻辑
  2. 修复死锁:避免、检测恢复、不闻不问
  3. 实际项目中这样干:
    1. 设置超时时间
    2. 多用并发类而不是自己设计锁
    3. 尽量降低锁的粒度
    4. 优先使用同步代码块而不是同步方法
    5. 给线程起个有意义的名字呗
    6. 避免锁的嵌套(顺序相反容易死锁)
    7. 银行家算法

展开

定位死锁:

  • java命令:
    1. 找到发生死锁的程序的pid(可以使用命令行或者Mac的Sloth工具)
    2. 执行${JAVA_HOME}/bin/jstack pid命令
  • ThreadMXBean代码:

修复死锁:

  1. 避免策略:哲学家就餐换手、避免相反的获取锁的顺序(转账问题:两个线程,先获取自己锁,再获取对方锁,很容易持有并等待)
  2. 检测与恢复策略:强行剥夺
  3. 鸵鸟策略:直到发生再人工修复

实际做法:

  1. 设置超时:Lock的tryLock(long timeout, TimeUnit unit),因为synchronize不具备尝试获取锁的能力
  2. 使用并发类:atomic
  3. 降低锁的粒度:用不同的锁而不是同一个锁,尽量保护区域小
  4. 使用同步代码块:自己指定锁对象

相关知识点

定位java内存异常:

  1. jps -l
  2. jmap -dump:live,format=b,file=d:\dump\heap.hprof
  3. jvisualvm

哲学家就餐:

/**
 * 哲学家就餐问题导致的死锁
 */
public class DiningPhilosophers {

    public static class Philosopher implements Runnable {

        private Object leftChopstick;

        public Philosopher(Object leftChopstick, Object rightChopstick) {
            this.leftChopstick = leftChopstick;
            this.rightChopstick = rightChopstick;
        }

        private Object rightChopstick;

        @Override
        public void run() {
            try {
                while (true) {
                    doAction("Thinking");
                    synchronized (leftChopstick) {
                        doAction("Picked up left chopstick");
                        synchronized (rightChopstick) {
                            doAction("Picked up right chopstick - eating");
                            doAction("Put down right chopstick");
                        }
                        doAction("Put down left chopstick");
                    }
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        private void doAction(String action) throws InterruptedException {
            System.out.println(Thread.currentThread().getName() + " " + action);
            Thread.sleep((long) (Math.random() * 10));
        }
    }

    public static void main(String[] args) {
        Philosopher[] philosophers = new Philosopher[5];
        Object[] chopsticks = new Object[philosophers.length];
        for (int i = 0; i < chopsticks.length; i++) {
            chopsticks[i] = new Object();
        }
        for (int i = 0; i < philosophers.length; i++) {
            Object leftChopstick = chopsticks[i];
            Object rightChopstick = chopsticks[(i + 1) % chopsticks.length];
            if (i == philosophers.length - 1) {
                philosophers[i] = new Philosopher(rightChopstick, leftChopstick);
            } else {
                philosophers[i] = new Philosopher(leftChopstick, rightChopstick);
            }
            new Thread(philosophers[i], "哲学家" + (i + 1) + "号").start();
        }
    }
}

  • 避免策略:服务员检查、改变一个哲学家的拿筷子的顺序、定义4张餐票,不允许5个人同时开始并持有。
  • 检测与恢复:领导调节,强行让一个放弃

活锁:等待相同时间,不断重试。程序一直在运行,但是毫无意义。

  • 吃东西相互谦让:如果你饿,我也饿,那我把勺子给你,你先吃(随机因素,小概率我先吃吧)
  • 消息队列:想法是,我有一个消息很重要,它要是失败我就把它放在队列头部并且一直重试(解决方法:放在队列尾部、重试限制,如果超过限制,放入数据库,使用定时任务去做)
  • 以太网的指数退避算法