android 多线程 — 从一个小例子再次品位多线程

今天回味 volatile 时看到了别人的一个 Demo:

class VolatileDemo() {

    var flag: Boolean = false

    fun read() {
        while (!flag) {
            Log.d("AA", "读取任务ing...")
//            Thread.sleep(100)
        }
        Log.d("AA", "读取任务结速")
    }

    fun write() {
        flag = true
        Log.d("AA", "写入任务完成")
    }
}
            var volatileDemo = VolatileDemo()

            var thread1 = Thread(Runnable { volatileDemo.write() })
            var thread2 = Thread(Runnable { volatileDemo.read() })

            //我们让线程2的读操作先执行
            thread2.start()
            //睡30毫秒,为了保证线程2比线程1先执行
            Thread.sleep(30)
            //再让线程1的写操作执行
            thread1.start()

并发没有 volatile 的表现

读取和写入操作中的 Flag 没用 volatile 标记,这时大家猜猜线程会怎么运行,这个例子当初有人 用来解释 volatile 的内存可见性,说 thread2 栈帧中的内存副本不会同步更新,即便 thread1 修改了 flag 的值,thread2 也会一直卡在这个循环里出不来。但是...重点是但是,这是不对的,thread2 还是能结束的,只是每次 thread2 每次表现都不一样,谁也不知道 thread2 在刷新 flag 数据之前会运行多少次

我们多运行几次,看看打印情况


1
2
3

结果完全超出我们认知啊,这运行起来完全没有规律可言,明明我们没用 volatile 标记 flag ,但是为什么图1、图3 这么像 volatile 啊,但是图2缺不是,这怎么理解,这就要盘盘 JVM 工作内存和主内存了


JVM 工作内存和主内存

JVM 把内存分割为:主内存 | 工作内存 2个部分:
  • 主内存 - 堆内存和本地方法区
  • 工作内存 - 每个线程都有一个工作内存,工作内存中主要包括两个部分,一个是属于该线程私有的栈和对主存部分变量拷贝的寄存器(包括程序计数器PC和cup工作的高速缓存区)

每条线程都有自己的工作内存,工作内存中保存的是主存中某些变量的拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量,线程之间无法直接访问对方的工作内存中的变量,线程间变量的传递均需要通过主内存来完成

JVM规范定义了线程对内存间交互操作:
  • Lock(锁定) - 作用于主内存中的变量,把一个变量标识为一条线程独占的状态。
  • Read(读取) - 作用于主内存中的变量,把一个变量的值从主内存传输到线程的工作内存中。
  • Load(加载) - 作用于工作内存中的变量,把read操作从主内存中得到的变量的值放入工作内存的变量副本中
  • Use(使用) - 作用于工作内存中的变量,把工作内存中一个变量的值传递给执行引擎。
  • Assign(赋值) - 作用于工作内存中的变量,把一个从执行引擎接收到的值赋值给工作内存中的变量。
  • Store(存储) - 作用于工作内存中的变量,把工作内存中的一个变量的值传送到主内存中。
  • Write(写入) - 作用于主内存中的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中。
  • Unlock(解锁) - 作用于主内存中的变量,把一个处于锁定状态的变量释放出来,之后可被其它线程锁定。

是不是有点看的眼花缭乱啦,仔细看这些其实都是顺序执行的操作,很好理解,知道就可,同样这些操作有自己的特性:

  • read - load,store - write 都是成对进行的,不允许单一出现使用
  • 不允许线程丢弃它最近的一个 assign 操作,即变量在工作内存被更改后必须同步改更改回主内存
  • 工作内存中的变量在没有执行过 assign 操作时,不允许无意义的同步回主内存

多线程并发的核心其实就是对于资源的可见性和有序性的处理

  • 可见性 - 对于可见性来说,什么时候把线程工作内存中的变量副本同步到主内存中完全是 JVM 自己实现系统决定的,我找了好多资料也没有明确说明的,更具上面例子的测试,我发现有时候对数据的修改会马上同步到主内存,有时候要等到线程上下文切换时才会更新数据。另外再说一点,使用 volatile 同样也会由工作内存的问题,区别是工作内存中的修改会马上立即同步到主内存
  • 有序性 - 有序性这个大家应该都门清,就是严格保证代码按照我们的逻辑执行,上面的例子就是个反面典型,执行成啥样我们完全控制不了

通过上面这个例子,就明明白白带出了多线程我们关心什么,多个线程同时对相同资源的使用,只要我们的代码中类似上面要处理相同的资源,那么我们必须要采用合适的多线程测量,否则执行成啥样谁知道


并发添加 volatile 的表现

还是上面的代码,我们给 flag 加上 volatile

    @Volatile
    var flag: Boolean = false

然后我们看看运行情况:


不管点几次都是读取先完事,然后再试写入完事,这样的确是保证了内存可见了,我们在任何地方修改一个 Volatile 的变量,所有改变量的副本都会立马相应,可以看到影响的速度是很快的,快的写入都来不急执行下面的任务,读取那边就完成同步了

但是从结果上看光是有 Volatile 还是不行的,逻辑上读取操作结束应该在写入完成之后执行的,这样看来 Volatile 并不能解决根本问题,还是得 Synchronized

很多人都说用 Volatile 做多线程同步必须小心再小心,通过这一个小小的例子就很明显了,Volatile 的缺陷太大,无法保证连续性和逻辑性,Volatile 最适合的场景就是赋值操作了,典型的就是单例了对吧,这个大家都知道


并发添加 Synchronized 的表现

那么写到这就完事了吗,还没有,最常用的多线程同步手段 Synchronized 我们还没用呢,既然上面 volatile 保证不了连续性逻辑性,那么我们来看看 Synchronized ,我们给写入和读取方法都改成 Synchronized 的

class VolatileDemo(var index: Int) {

    var flag: Boolean = false

    @Synchronized
    fun read() {
        while (!flag) {
            Log.d("AA", "读取任务ing... - 第:$index 点击")
//            Thread.sleep(100)
        }
        Log.d("AA", "读取任务结速 - 第:$index 点击")
    }

    @Synchronized
    fun write() {
        flag = true
        Log.d("AA", "写入任务完成 - 第:$index 点击")
    }

}

但是结果呢,thread2 真的卡在这里了,thread2 拿到锁一直运行不释放锁,thread1 怎么由机会执行呢,就会想下面 log 输出一样,一直跑停不下来



并发 volatile + Synchronized 的表现

我们继续修改代码

class VolatileDemo(var index: Int) {

    @Volatile
    var flag: Boolean = false

    @Synchronized
    fun read() {
        while (!flag) {
            Log.d("AA", "读取任务ing... - 第:$index 点击")
//            Thread.sleep(100)
        }
        Log.d("AA", "读取任务结速 - 第:$index 点击")
    }

    @Synchronized
    fun write() {
        flag = true
        Log.d("AA", "写入任务完成 - 第:$index 点击")
    }
}

是不是有人对此很期待啊,肯定有人听说过多线程使用 volatile + Synchronized 来做,但是结果吧和上面单独使用 Synchronized 一样,thread2 一直运行,thread1 没有执行的机会,可见多线程设计的复杂性,你这边的逻辑说不准就会这样。不要迷信网上有人说的 volatile + Synchronized 万能论调,存扯淡

那我们应该怎么办,显然这种单单依靠 flag 在多线程中异常危险

  • 常规方式 - 我们可以放弃这个 flag 标记,完全使用 Synchronized 来实现同步,但是 Synchronized 由局限性,Synchronized 修饰的是整个方法,只能同步整个方法的执行,而不能在方法执行的过程中进行操作
  • 自由加锁 - 若是我们需要在方法中根据情况不筒进行不同的同步操作,那么就剩下自己加锁这种选择了,这样可以实现更精细的操作

并发 ReentrantLock+ Condition的表现

没啥说的直接看代码

class VolatileDemo {

    @Volatile
    var flag: Boolean = false
    var reentrantLock = ReentrantLock()
    var condition = reentrantLock.newCondition()

    fun read() {
        try {
            reentrantLock.lock()
            Log.d("AA", "开始读取任务")
            if (!flag) {
                Log.d("AA", "没有数据,进入待机状态,释放锁")
                condition.await()
                Log.d("AA", "没有数据,被唤醒再进入")
            }
            Log.d("AA", "读取任务结速")
            condition.signalAll()
        } finally {
            reentrantLock.unlock()
        }
    }

    fun write() {
        try {
            reentrantLock.lock()
            flag = true
            Log.d("AA", "写入任务完成")
            condition.signalAll()
        } finally {
            reentrantLock.unlock()
        }
    }

}
            var volatileDemo = VolatileDemo()

            var thread1 = Thread(Runnable { volatileDemo.write() })
            var thread2 = Thread(Runnable { volatileDemo.read() })

            //我们让线程2的读操作先执行
            thread2.start()
            //睡1毫秒,为了保证线程2比线程1先执行
            Thread.sleep(30)
            //再让线程1的写操作执行
            thread1.start()

这里我们还是基于 flag 标记进行逻辑操作,所以 flag 还是要设计成 Volatile 的,然后我们自己加锁,自己阻塞,自己唤醒,阻塞的代码在被唤醒的地方继续执行,这样整个逻辑我们恩那个完全按照自己的思路去做


感想

volatile 好久之前就看过了,这次精研多线程时又看了看当初的文章,于是又看到了这个小例子,看过之后马上反应过来由问题,左想不对,右也不对,谁说线程有自己的工作内存,核心标记也不是 volatile 可见的,但是 Thread2 是循环不挺的执行,不可能内存一直不刷新的,只是执行时间长短的问题,索性我把这个例子好号走走得了

然后连带着想了很多问题,比如线程工作内存何时同步到主内存,多线程的几种手段都是为了达到什么目的,意义,优势,缺陷?我是挨个试了个遍,还真是实践见真章,自己掠过这么一遍之后感觉多线程的手段在脑海里彻底清晰起来,写文章的意义也是在这里


参考资料:

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

推荐阅读更多精彩内容

  • 线程池ThreadPoolExecutor corepoolsize:核心池的大小,默认情况下,在创建了线程池之后...
    irckwk1阅读 737评论 0 0
  • 以上代码会重复运行 , 不会停止。 JMM(java内存模型) 若想学习好多线程, 那么必须了解一下JMM Jav...
    尼尔君阅读 1,761评论 0 2
  • 除了充分利用计算机处理器的能力外,一个服务端同时对多个客户端提供服务则是另一个更具体的并发应用场景。衡量一个服务性...
    胡二囧阅读 1,368评论 0 12
  • 前言 这篇文章主要是对多线程的问题进行总结的,因此罗列了40个多线程的问题。 这些多线程的问题,有些来源于各大网站...
    java成功之路阅读 837评论 0 9
  • Java-Review-Note——4.多线程 标签: JavaStudy PS:本来是分开三篇的,后来想想还是整...
    coder_pig阅读 1,665评论 2 17