Java并发编程实战理论基础

可见性、原子性和有序性

为了合理利用CPU的高性能,平衡CPU、内存、IO的速度差异,计算机体系机构、操作系统、编译程序都做出了贡献,主要体现为:

  • CPU增加了缓存,以均衡与内存的速度差异;
  • 操作系统增加了进程、线程,以分时复用CPU,进而均衡CPU与IO设备的速度差异;
  • 编译程序优化指令执行次序,使得缓存能够得到更加合理利用。

一个线程对共享变量的修改,另一个线程能够立刻看到,成为可见性。

把一个或者多个操作在CPU执行的过程中不被中断的特性成为原子性。

解决原子性的问题,是要保证中间状态对外不可见。

public class LazyMan {
    private LazyMan(){

    }
    private static LazyMan lazyMan;

    public static LazyMan getInstance(){
        if (lazyMan == null){
            synchronized (LazyMan.class){
                if (lazyMan == null){
                    lazyMan = new LazyMan();
                }
            }
        }
        return lazyMan;
    }
}

上面的执行流程为:
1.分配一块内存M;
2.在内存M上初始化 LazyMan 对象;
3.然后M的地址复制给lazyMan变量;

为了提高执行速度,做一些优化:编译器优化的重排序、指令级并行的重排序、内存系统的重排序。

优化后的路径为:
1.分配一块内存M;
2.然后M的地址复制给lazyMan变量;
3.在内存M上初始化 LazyMan 对象;

这样就会产生半初始化的对象的情况,为了解决这种问题,使用volatile来修饰变量,就会插入内存屏障,禁止冲排序。

Java内存模型如何解决可见性和有序性问题

Java内存模型是个很复杂的规范,可以从不同的视角来解读,本质上是,Java内存模型规范了JVM如何提高按需金庸缓存和编译优化。

happen-before原则

前面一个操作的结果对后续操作是可见的。

  • 程序的顺序性原则。
  • volatile变量原则。
  • 传递性。
  • 管程中锁的原则(synchronized)。
  • 线程start原则。
  • 线程join原则。
class Account{
    private int balance;
    void transfer(Account transfer,int amt){
        synchronized(Account.class){
            if(this.balance > amt){
                this.balance -= amt;
                target.balance = amt;
            }
        }
    }
}

原子性问题的源头是线程切换,操作系统做线程切换是依赖CPU中断的,所以禁止CPU发生中断就能够禁止线程切换。

如何预防死锁

1.破坏占用切等待条件

public class Allocator {
    private List<Object> als = new ArrayList<>();

    /**
     * 一次性申请所有资源
     * @param from
     * @param to
     * @return
     */
    synchronized boolean apply(Object from,Object to){
        if (als.contains(from)||als.contains(to)){
            return false;
        }else {
            als.contains(from);
            als.contains(to);
        }
        return true;
    }

    /**
     * 归还资源
     * @param from
     * @param to
     */
    synchronized void free(Object from,Object to){
        als.remove(from);
        als.remove(to);
    }

}
class Account{
    private Allocator allocator;
    private int balance;

    /**
     * 转账
     * @param target
     * @param amt
     */
    void transfer(Account target,int amt){
        while (!allocator.apply(this,target)){

        }
        try {
            // 锁定转出账号
            synchronized (this){
                // 锁定转入账号
                synchronized (target){
                    if (this.balance > amt){
                        this.balance -= amt;
                        target.balance += amt;
                    }
                }
            }
        }finally {
            allocator.free(this,target);
        }
    }
}

2.破坏不可抢占条件
3.破坏循环等待条件

public class Account {
    private int id;
    private int balance;

    /**
     * 转账
     * @param target
     * @param amt
     */
    void transfer(Account target,int amt){
        Account left = this;
        Account right = target;
        if (this.id > target.id){
            left = target;
            right = this;
        }
        // 锁定序列号小的账号
        synchronized (left){
            // 锁定序号大的账号
            if (this.balance > amt){
                this.balance -= amt;
                target.balance += amt;
            }
        }
    }
}

wait 与sleep的区别

1.原理不同:sleep 是Thread的静态方法,是线程用来控制自身流程的,将此线程暂停执行一段时间,而把执行机会让给其他线程;
wait是Object类的方法,用于线程间的通信,这个方法会使当前拥有该对象的进程等待,直到其他线程调用notify方法,才会醒来。

2.对锁的处理机制不同:sleep 不会释放锁;调用wait之后,线程会释放所占用的锁。

3.使用区域不同:wait方法必须放在同步控制方法或者同步语句块中使用;sleep方法可以放在任何地方使用。

4.sleep方法必须捕获异常,不释放锁标志,有可能会出现死锁问题;wait、notify、notifyAll不需要捕获异常。

sleep 与yield的区别

1.sleep 方法给其他线程运行机会时不考虑线程的优先级,因此会给低优先级的线程以运行的机会;yield方法只会给相同优先级或者更高优先级的线程以运行的机会。
2.线程执行sleep方法后会进入阻塞状态,所以,执行sleep方法的线程在指定的时间内肯定不会被执行;yield方法只是使当前线程重新会到可执行状态,所以执行yield
方法的线程有可能在进入到可执行状态后马上又被执行。
3.sleep方法声明抛出InterruptedException;yield方法没有声明任何异常。
4.sleep方法比yield方法具有更好的移植性。

安全性、活跃性以及性能问题

安全性

如果访问共享资源,保证互斥,或者顺序执行,就不会存在安全性问题,否则就会出现安全性问题。

活跃性

有时线程虽然没有发生阻塞,但仍然会在执行不下去的情况,这就是所谓的活锁。解决活锁是设置随机的时间。

所谓饥饿指的是线程因无法访问所需资源而无法执行下去的情况。

解决饥饿的方法:保证资源充足;公平分配资源;避免持有锁的线程长时间执行。

性能问题

从方案层面说:
1.既然使用锁会带来性能问题,那最好的方案自然就是使用无锁的算法和数据结构。
例如:Thread Local Storage(TLS)、Copy-On-Write、乐观锁、AtomicXXX、Disruptor。
2.减少锁持有问题。可以减少锁的力度,例如ConcurrentHashMap

性能方面的指标:
1.吞吐量:单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
2.延迟:从发出请求到响应的时间。延迟越小,说明性能越好。
3.并发量:同时处理的请求数量。

管程

Java采用的是管程技术,synchronized关键字及notify、wait、notifyAll这个三个方法都是管程的组成部分。
管程和信号量是等价的,所谓的等价指的是用管程能够实现信号量,也能用信号量实现管程。

所谓管程,指的是管理共享变量以及对共享变量的操作过程,让他们支持并发。

管程解决互斥问题的思路很简单,就是将共享变量及其对共享变量的操作统一封装起来。

实现管程的方式:
1.synchronized + wait + notify/notifyAll。
2.Lock + condition

Java线程的声明周期

通用的线程生命周期

1.初始状态:线程已经被创建,但是还不允许分配CPU执行。这个只是编程语言层面被创建,在操作系统层面,真正的线程还没有创建。
2.可运行状态:线程可以分配CPU执行。真正的操作系统线程已经被成功创建,可以分配CPU执行。
3.运行状态:当有空闲的CPU,操作系统会将期分配给一个处于可运行状态的线程,被分配到CPU的线程转换为运行状态。
4.休眠状态:运行状态调用一个阻塞的API或者等待事件,运行状态转换为休眠状态,释放CPU使用权,休眠状态永远没有机会获得CPU使用权。等待事件出现了,从休眠状态变成可运行状态。
5.终止状态:线程执行完成或者出现异常才会进入终止状态,终止状态不会切换到其他任何状态。

Java中线程的生命周期

Java中线程的生命周期:NEW(初始化状态)、RUNNABLE(可运行/运行状态)、BLOCKED(阻塞状态)、WAITING(无限时等待)、TIMED_WAITING(有时限等待)、TERMINATED(终止状态)。

RUNNABLE与BLOCKED的状态转换

synchronized修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,这种情况下,等待的线程会从RUNNABLE切换到BLOCKED状态。
等待的线程获取的synchronized隐士锁时,就会从BLOCKED转换到RUNNABLE状态。

RUNNABLE与WAITING的状态转换

分为三种方式:
1.获得synchronized隐士锁的线程,调用无参数的object.wait方法。
2.调用无参数的Thread.join()方法。
3.调用LockSupport.park()方法。

RUNNABLE与TIMED_WAITING的状态转换

1.调用带超时参数的Thread.sleep(long millis)方法;
2.获得synchronized隐士锁的线程,调用带有超时参数的Object.wait(long timeout)参数;
3.调用带有超时参数的Thread.join(long millis)方法;
4.LockSupport.parkNanos(Object blocker,long deadline);
5.LockSupport.parkUntil(long deadline);

从NEW到RUNNABLE状态

1.创建的Thread,执行run方法
2.实现Runnable接口,重写run方法。

从RUNNABLE到TERMINATED状态

调用stop()(不建议使用)、interrupt()方法。

stop与interrupt的区别:stop方法会真的杀死线程;interrupt方法是通知线程,线程有机会执行一些后续操作,同时可以无视这个通知,接收到通知的方式:一种是异常,另一种是主动检测。

创建多少个线程才是合适的

在并发变成领域中,提升性能本质上就是提升硬件的利用率,在具体点来说,就是提升IO的利用率和CPU的利用率。

对于CPU密集性的场景,线程数 = CPU核数。
对于IO密集性的场景,线程数 = 2*CPU核数。

对于多核情况,有一个公式: 最佳线程数 = CPU核数 * [1 + (I/O耗时 / CPU耗时)]

为什么局部变量是线程安全的

栈帧和方法是同生共死的。局部变量就是放到了调用栈里。每个线程都有自己独立的调用栈。
方法里的局部变量,不会有并发问题,仅在单线程内访问数据。

如何用面向对象思想写好并发程序?

封装共享变量

将共享变量作为对象属性封装在内部,对所有公共方法指定并发访问策略。对于这些不会发生变化的共享变量,建议使用final关键字来修饰。

识别共享变量间的约束条件

一定要识别出所有共享变量之间的约束条件,如果约束条件识别不足,很可能导致制定的并发访问策略。
要考虑具体场景的一些约束,例如:库存下限要小于库存上限。

查找问题

top -c 查找那个进程占用的资源CPU多
top -Hp 查找那个线程出现的问题
jstack 查看线程的栈信息

参考文献

Java内存模型(JMM)总结

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343