美文网首页Android 开发
Java多线程、synchronize原理

Java多线程、synchronize原理

作者: z白依 | 来源:发表于2020-01-12 10:16 被阅读0次
    • 进程和线程有什么区别
    • synchronize 的本质是什么
    • 死锁是什么
    • 乐观锁和悲观锁是什么
    • volatile 关键字有什么作用

    本篇记录Java多线程和线程同步(synchronize),带着问题学习才是最有效的方法。下面会对上述问题由浅入深,进行原理性的讲解,大部分会用最简单的例子讲解到原理。理解了才是自己的知识。

    开启线程


    先来个最简单的如何开启一个新线程:

    Thread thread = new Thread() {
        @Override
        public void run() {
            System.out.println("Thread started!");
        }
    };
    thread.start();
    

    上面的代码就是开启一个线程执行打印。调用一个 thread.start()就能开启一个新线程,在后台执行一个打印操作,那么会不会有疑问,这个是怎么做到开启一个新线程的呢?

    来看下 start() 方法

    public
    class Thread implements Runnable {
        ...
        public synchronized void start() {
            ...
            start0();
            ...
        }
        private native void start0();
        ...
    }
    

    可以看到,最终调用了 native 方法。看见 native,如果不是做NDK开发的话就可以停下来了,一个 native 方法就是一个本地方法,是虚拟机跟平台沟通的。

    什么叫平台

    比如说Java虚拟机是运行在 Android 上就是 Android平台,还有Windows平台,Mac平台等。

    Java到这里已经不负责实际的交互了,它只是跟虚拟机去做交互,它告诉虚拟机“我需要什么操作”,虚拟机就会根据具体的平台做出不同的行为。也就是说这个具体行为已经不是Java代码可以控制的了。所以知道这做了什么就好,因为不同平台实际代码也不一样。

    本地方法 start() 做了什么

    Java告诉虚拟机,开一个新线程,在新线程里面执行 run() 方法,那么这个 run() 方法是怎么切换到另一个线程来执行的呢?它是虚拟机去指挥操作系统来做的,不同的操作系统它们的切换方式是不一样的。Java本身是没有切换线程的能力的,它都是需要交给虚拟机,虚拟机再交给系统来做的,一个语言是没办法切线程的,它只是一级一级的去指挥。

    new Runnable() 和 new Thread() 区别

    顺带提一下,这两个可能都是常用的方法,有没有想过这两个有什么区别。先来看下 Runnable 怎么用:

    Runnable runnable = new Runnable() {
        @Override
        public void run() {
            System.out.println("Thread with Runnable started!");
        }
    };
    Thread thread = new Thread(runnable);
    thread.start
    

    看下源码:

    public
    class Thread implements Runnable {
        ...
        /* What will be run. */
        private Runnable target;
        ...
        private void init(ThreadGroup g, Runnable target, String name,
                          long stackSize, AccessControlContext acc) {
            ...
            this.target = target;  
            ...                
        }
        
        @Override
        public void run() {
            if (target != null) {
                target.run();
            }
        }
        ...
    }
    

    可以看到在 Thread 初始化的时候会把 Runnable 对象赋值给 target,在 run() 方法中如果target不为null会调用 Runnable 的 run() 方法。由此可以看出了这两个用法最终都是一样的。所以这两个本质上是没什么区别的。用 Runnable 的话可以更好的复用。

    进程和线程


    很多次面试都会被问到这两个的区别。总的来说 进程大于线程,线程运行在进程里面。

    进程

    进程就是操作系统上一块独立的区域,独立运行,它和其它进程相互分开,有自己的数据管理,和别的进程不共享。

    线程

    一个进程中可以有多条线路在并行做事,这些线路每一条就是一个线程。

    进程和线程它们的区别就在于,线程之间共享资源,进程之间不共享资源。可能听不同,举个例子:进程就是好比一个家庭,线程是每个家庭成员,其中有一个变量“钱”,家庭之间肯定是不能共享这个变量的,家庭成员可以共享。

    再从本质上讲,它们根本就不是同一个概念,一个是运行程序,是进程;一个是程序中并行的线路。它们之所以拿来对比,是因为它们都具有可以并行的特性。线程可以并行工作,进程也可以并行工作,但是事情本来是不应该这么对比的。就跟家庭和家庭成员有什么区别,根本不是一个层面的概念。线程是比进程更细的概念。

    CPU线程和操作系统线程区别

    CPU线程是什么,比如四核CPU八核CPU,它是CPU这个东西在硬件级别能够同时做的事情的数,比如,CPU被设计为同时能做8件事,它就是一个八核CPU。

    那么什么是操作系统线程呢?它是另外一回事,它是通过时间分片这种方式,把CPU线程再给切开进行进一步的拆分,它相当于是对CPU线程进行了一种模拟。比如早年的单核,它也能执行多线程的操作系统,光一个程序里面就有好多线程,因为操作系统把CPU的线程也拆分了。平时开发程序一般说什么什么线程,开个后台线程这些都是说的操作系统线程。

    再说一个四核八线程,通过技术让一个核实际上可以运行出来两个CPU线程,本质上还是四线程,但是在CPU级别它把线程一拆为二了。

    线程是什么

    简单来说就是按照代码顺序执行下来,执行完毕就结束,这么一条线的东西,每个线程都会有一个开始一个结束,每个线程一定都会结束的,只要事情做完了就结束,如果有循环,循环完也是要结束的。什么情况下不结束?死循环。

    那么问题来了,Android 的 UI线程为什么不会结束呢?

    一个软件启动了,各种初始化完毕,开始进入界面,一个界面刷新,刷新完了,循环进入下一次界面刷新,也就是下一帧。

    这是一个死循环为什么不卡界面?死循环难道不卡吗?会不会这么想这里一直死循环,那后面的代码就会执行不到。要知道执行不到的是循环后面的代码,而不是循环,不是下一帧界面刷新,导致界面卡的是下一帧界面刷新来得晚或者不来。而且正是因为这个死循环我们的界面才能够反复刷新。

    什么情况卡界面?

    如果在循环里面出现一个小的死循环,那么就会在这一次界面刷新的时候(循环)界面就卡死了,导致这一次循环永远结束不了,下一次循环永远无法到来,下一帧永远无法刷新了。这才是我们所感知到的界面的卡死。

    总结下来就是每一次界面刷新靠的就是这个死循环。只要不是在同一次刷新里面有小的死循环导致下一次刷新到达不了,界面就会不断刷新了。

    synchronize


    本质

    • 互斥性:数据的访问互斥,不是方法的访问互斥而是数据的访问互斥。
    • 同步性:数据的同步,线程之间对它们监视的资源的数据同步,也就是任何线程对于数据的修改对于其他线程都是立即可见的。
    同步性

    先来说一下同步性,比如 线程A 把一个数值一个变量从0改成了1,线程B再下一刻取这个变量值得时候取到的就是1而不是0。

    ?这不是本来就是这样的吗?不是的,本来不是这样的,Java为了性能做了一件很让人意外的操作:内存拷贝模型。

    内存拷贝模型

    简单来说就是比如内存中有一个 x = 0。我们所以为的,某一时刻线程A要改就到内存中改,要取就到内存中取。其实不是的,实际上是线程A要操作内存数据的时候,会拿个拷贝去复制一份,复制到线程A中来,线程B也会复制一份到线程B中。如果 x 的初始值是0,那线程A拿到的也是0,然后线程A把 x 值改成1,改完之后就完了,就是说这个1不往内存中写了,不是永远不写,而是它自己会选一个合适的时机来写。就是说别的线程想取这个值的时候,它不一定会往内存中写的,它会选一个性能上合适的时机。那么这个时候如果线程B想要取值,取到的值会是0。然后线程B把值改成2,如果第三个线程要取,可能还是取的0。

    为什么这么设计,这样很危险的。谁不知道危险呢,但是没办法,为了性能这种设计就被设计出来了,这种设计它的性能优化是超级大的。

    它是怎么实现的呢,这就要看具体虚拟机的实现了,一般的都是CPU的高速缓存,CPU有个东西叫cash,是CPU的高速缓存,这个CPU高速缓存是非常快的,比内存要快得多,我们知道内存比硬盘快,现在的硬盘都用固态硬盘了,速度差别会小不少,但是内存的速度跟CPU的高速缓存比起来还是要慢得多的,CPU的高速缓存特别的快,所以这种设计虽然危险,还是被设计出来使用了。

    危险是相对的,不懂的话那么就危险,一旦会了,只要去做优化这个危险就没了。

    说了这么,来看一个例子:下面的代码就是两个线程都执行 x++ 1_000_000 次,那么结果应该是 2_000_000。但是不管运行多少次,都没有结果是 2_000_000 的。

    private int x = 0;
    
    private void count() {
        // int i = x + 1;
        // x = i;
        x++;
    }
    
    public void runTest() {
        new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 1_000_000; i++) {
                    count();
                }
                System.out.println("x from 1: " + x);
            }
        }.start();
        new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 1_000_000; i++) {
                    count();
                }
                System.out.println("x from 2: " + x);
            }
        }.start();
    }
    

    为什么会这样的,首先就像是上面讲的因为同步性,还有就是互斥性,这两个线程访问 count() 方法不是互斥的,简单点说就是第一个线程执行count()方法的时候,第二个线程也能执行。这么讲又有点不对(互斥是对数据的互斥,而不是方法),这里先解释下原子操作。

    互斥性

    不能访问同一个 monitor 的 synchronize代码块

    原子操作

    对于CPU来说它是一个操作,它是不可拆分的,如果它对于CPU来说不可拆分那么对于线程来说也是不可拆分的。CPU想要切线程,要么等它执行完,要么让它先别执行。

    x++ 这个操作对于CPU来说不是原子操作,它会执行两步,就跟上面的注释差不多。这个时候一个线程执行完(int i = x + 1;)正准备执行(x = i;)的时候,假设 i 被赋值了3。这个时候另一个线程循环了10次,x=10了。接着再执行第一个线程(x = i;)x又等于3了。就导致了结果不等于逾期。

    这个时候就要保证x++的原子性了。使得x++对于CPU来说就是一个操作。也就是说一个线程在执行x++的时候不会被打断。保证了互斥性。

    加上synchronize就可以解决这两个问题了:

        private synchronized void count() {
            x++;
        }
        // 或者
        private void count() {
            synchronized (this) {
                x++;
            }
        }
    

    上面两种方式是一个意思,因为它们的 monitor 是同一个。解释下monitor是什么

    monitor

    监视器:监视代码最多有一个线程访问

    如果 monitor 用的不是同一个对象:

    private final Object monitor1 = new Object();
    private final Object monitor2 = new Object();
    
    private void A() {
      synchronized (monitor1) {
        ...
      }
    }
    
    private void B() {
      synchronized (monitor2) {
        ...
      }
    }
    

    当 A() 被执行的时候 B() 也是可以被执行的。它们两个monitor不是同一个,不互斥。也可以理解为没关系,它们互不影响。

    如果用的都是一个 monitor 的话,那么A() 被执行的时候B()就不能执行了,它们就相当于同一个代码块了。

    synchronize 代码块的作用:对于CPU来说是原子操作,这是一个整体。以及 在线程获取到monitor之后的第一时间就将共享内存的数据复制到自己的缓存里面。在释放monitor的第一时间会把值放到共享内存中去就不会是那个所谓的合适的时间了。这样就把内存拷贝的机制关闭了,每一个线程相当不再存在自己的内存区域了。这样就安全了。

    线程安全

    怎么就线程安全了呢?不要由于不当的使用线程导致程序出错,这个就是线程的安全,就是线程的 safety。对已知的问题的防护就是线程安全。

    死锁

    private final Object monitor1 = new Object();
    private final Object monitor2 = new Object();
    
    private void A() {
      synchronized (monitor1) {
        ...①
        synchronized (monitor2) {
            ...②
        }
      }
    }
    
    private void B() {
      synchronized (monitor2) {
        ...③
        synchronized (monitor1) {
            ...④
        }
      }
    }
    

    比如上面这种,线程A执行到①的时候,线程B正好执行到③,但是monitor1 管着 monitor1 的 synchronize 代码块,现在有线程占着 monitor1,monitor1就会把其他线程挡在外面,所以线程B就进不去④。A也是同理,永远到不了②。这样就出现了死锁。

    乐观锁和悲观锁

    ==数据库上面的锁,跟线程没关系。==

    在访问数据的时候,我把数据库里面的数据拿出来去做修改,我用这个数据去做计算,计算完之后再去存,在我拿出来在往回填之前是有可能别人就把这个数据给写完的导致数据出错。

    比如:把金额50加50,你也想加50,我加完是100,你加完是150。如果我们两个同时做,我拿是50,你拿也是50,我加完是100,你加完也是100,我写100,你也写100,那么结果是,少50,这就是数据库锁有用的地方。

    简单方案是什么呢,我把数据拿出来的时候,谁都不许访问这数据库了,那就简单了,我拿出来慢慢的算,算完之后往里面写,写完之后你发现终于可以用了,拿出来是100计算完之后往里面写150.

    乐观锁是什么呢?乐观锁就是我认为别人不会改,系统设计是比较少的往里面写的。所以这时候锁就不加了,那么我往里面写的时候检查一下里面的数据跟我当时拿的数据一样不一样,如果不一样了,我现在再加一个锁,再做重新的计算,而如果和本来的一样,说明没有人改,就直接写进去,这就是乐观锁。

    而悲观锁就是上面的那个,我拿出来的时候假设别人都会改,这是那种会频繁修改的场景。如果在频繁修改的场景使用乐观锁就会出现我总是第一次做的事失败,总是第一次把事情判断得过于乐观,导致性能比较差。

    单例的实现

    class SingleMan {
        private static SingleMan sInstance;
    
        private SingleMan() {
        }
    
        static SingleMan newInstance() {
            if (sInstance == null) {
                sInstance = new SingleMan();
            }
            return sInstance;
        }
    }
    

    可以看到上面代码没有同步性和互斥性,是线程不安全的。那就加 synchronize吧。

        ...
        static synchronize SingleMan newInstance() {
            if (sInstance == null) {
                sInstance = new SingleMan();
            }
            return sInstance;
        }
        ...
    

    现在虽然是安全了,但性能不是最好的。不管 sInstance 是不是 null,都要被monitor控制着。那就改一下吧。把synchronize写到等于null的里面。

        ...
        static SingleMan newInstance() {
            if (sInstance == null) {
                // ①
                synchronize(SingleMan.class) {
                    sInstance = new SingleMan();
                }
            }
            return sInstance;
        }
        ...
    

    emmm...还是有点小问题,比如 线程A 和 线程B 两个线程都执行到①了,这时候可能线程A会先进到 synchronize代码块里面,那么线程B就先等着吧,然后等A执行完了,B就可以进到 synchronize代码块了,这时就有问题了,又执行了 new。所以得改成下面这样。

        ...
        static SingleMan newInstance() {
            if (sInstance == null) {
                synchronize(SingleMan.class) {
                    if (sInstance == null) {
                        sInstance = new SingleMan();
                    }
                }
            }
            return sInstance;
        }
        ...
    

    好了,问题都解决了,完整的单例就实现了。

    再提一点 static 方法上用 synchronize 和 synchronize(XXX.class) 是一样。而不是用this了。static方法中是没有this的。

    volatile


    对于 volatile,大家可能对它只知道大概,具体点什么时候用可以什么时候用又不行,这个边界在哪里。下面简单讲一下

    volatile 有同步性,只对引用基本数据类型有同步性。还对 long,double有原子性,在Java中 long,double 这两种基本数据类型不是原子操作。

    同步性应该是能理解了,那它和 synchronize 的边界在哪呢,基本数据类型应该都知道,也就是这个 引用 指的什么。
    如:

    User user = new User()
    

    这个new是有同步性的。

    user.setName(name)
    

    这个set就没有同步性了。

    总结


    本文主要讲进程和线程,synchronize的本质,volatile以及一些其他的延伸。文章有点长,也是对自己学习的总结,希望对读者理解有所帮助。

    相关文章

      网友评论

        本文标题:Java多线程、synchronize原理

        本文链接:https://www.haomeiwen.com/subject/quyiactx.html