屌丝程序员们对自己的技术能力总是毫不掩饰的高调,更有甚者每当完成一个简单的功能或算法实现,恨不得从工位上跳起来,生怕谁不知道一样,心情能理解,但个人完全鄙视这种行为。说到底,大家日常的coding,大多在单线程下执行,代码书写的顺序即执行的顺序,很多时候也是我们解决问题的逻辑顺序。有很多代码,如果考虑多线程,从并发的角度去实现,伪“大牛”们可能就要原形毕露了,很多同学更是束手无策。那么,多线程真的那么可怕么?接下来本人 把自己的一些理解分享出来,如有不当,欢迎指正。
java多线程问题简单一点说就是同一时间内有多个java线程对同一份内存数据进行存取。如果业务逻辑上对该份数据的值变化过程有要求,那么多线程的执行顺序不确定性将打乱该要求从而引起问题.因此,搞明白以下三个事情,也就能有效的理解并规避上述问题。
1.线程如何对内存中的变量进行存取?
2.线程究竟以何种顺序执行?
3.多线程如何控制同步?
大家知道,由于计算机的存储设备与处理器的运算速度之间有着数量级之间的差距,因此java采用高速cache作为中间桥梁,在缓存一致性协议下与主内存同步,如下图(java内存模型):
java内存模型规定所有的变量都存储在共享内存中,每条线程都有属于自己的工作内存,该工作内存中保存了该线程使用到的变量的共享内存副本拷贝,线程在工作期间对变量的所有存取操作都只是针对此副本拷贝,而不是真正共享内存中的变量,不同的线程之间无法直接访问对方工作内存中的变量,线程执行完操作后,同步协议将副本拷贝与共享内存对应变量进行同步,由于虚拟机对线程的抢占式调度,当多个线程同时同步一份数据的时候,必然出现并发问题。因此,解决并发首先要了解此同步协议,简单点说,该同步协议主要有以下八种操作:
(1) lock:将共享内存中的变量锁定,为一个线程所独占
(2) unclock:将lock加的锁定解除,此时其它的线程可以有机会访问此变量
(3) read:将共享内存中的变量值读到工作内存当中
(4) load:将read读取的值保存到工作内存中的变量副本中。
(5) use:将值传递给线程的代码执行引擎
(6) assign:将执行引擎处理返回的值重新赋值给变量副本
(7) store:将变量副本的值存储到共享内存中。
(8) write:将store存储的值写入到共享内存的共享变量当中。
我们可以看到,要保证数据的同步,lock和unlock定义了一个线程访问一次共享内存的界限,有lock操作也必须有unlock操作,另外一些操作也必须要成对出现才可以,像是read和load、store和write需要成对出现,如果单一指令出现,那么就会造成数据不一致的问题。Java内存模型也针对这些操作指定了必须满足的规则:
(1) read和load、store和write必须要成对出现,不允许单一的操作,否则会造成从主内存读取的值,工作内存不接受或者工作内存发起的写入操作而主内存无法接受的现象。
(2) 在线程中使用了assign操作改变了变量副本,那么就必须把这个副本通过store-write同步回主内存中。如果线程中没有发生assign操作,那么也不允许使用store-write同步到主内存。
(3) 在对一个变量实行use和store操作之前,必须实行过load和assign操作。
(4) 变量在同一时刻只允许一个线程对其进行lock,有多少次lock操作,就必须有多少次unlock操作。在lock操作之后会清空此变量在工作内存中原先的副本,需要再次从主内存read-load新的值。在执行unlock操作前,需要把改变的副本同步回主存。
明白了java线程对变量的存取后,接下来需要知道的是线程究竟是以何种顺序执行,可以简单总结为一句话:”如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的“。即在线程内,指令的顺序表现为串行,在线程之间,指令表现为重排序以及工作内存与主内存的同步延迟。线程的执行有以下一些天然的顺序规则,粗略的说,如果两个操作之间的顺序关系不在以下规则里,并且也无法从以下规则中推导出,那么就可以断定它们是没有顺序性保障的,其行为不是线程安全的。
1.程序次序规则:在一个线程内,按照程序代码控制流(非书写顺序流,因为有分支,循环等),书写在前面的操作先行发生于后面的操作。这条规则保证了我们在日常的单线程环境下,能用业务逻辑的顺序去处理代码编写的顺序。当然,很多伪大牛们也在这条规则下coding的风生水起。不过此处仍有一个注意点,那就是JVM的优化有时候会导致指令重排,即后面的代码有可能先被处理器执行,比如 int a = 0; int b =1; 有可能执行器先给b赋值,然后给a赋值。这并不违反此规则,因为重排从线程内根本无法感知,无伤大雅,只是JVM做的一个优化。
2.管程锁定规则:对同一个锁,unlock操作先行发生于后面的lock操作。
3.volatile变量规则:对volatile修饰的变量,写操作先行发生于读操作。这也是大多数情况下我们使用volatile保证多线程操作变量的可见性的原因。
4.线程启动规则:Thread对象的start方法先行发生于此线程的每一个工作。这是必须
的。有点类似先取媳妇再生娃,未婚先育是不允许的。
5.线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测。
6.线程的中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。有点类似先有鸡,然后由鸡粪。
7.对象终结规则:一个对象的初始化完成先行发生于它的finalize方法的开始。有点类似先有男人,然后有人妖。
8.先行传递规则:如果操作A先行发生于B,操作B先行发生于C,那么操作A先行发生于C。
上面的规则是无须使用任何同步手段保障就会发生的,在单线程下,重点参考规则1,一般来说无须考虑过多的顺序问题,但在多线程下,从上面的规则可以看到,无论是对变量的存取还是对象方法的执行,都很难套用上述规则,因此大部分都是线程不安全的。
既然多线程下不安全,那如何控制同步以促使其安全呢,这也是我们需要回答的第三个问题。一般我们需要做好以下几点:
1.尽量将对象放在单线程里进行存取,其他线程不参与修改,即所谓的线程封闭。如局部变量,final修饰的不可变对象,ThreadLocal类封装等。
2.使用volatile修饰变量使其多线程可见,使用synchronized同步方法或代码块
3.使用线程安全的容器替代非线程安全的容器,如Hashtable,synchronizedMap,ConcurrentMap,CopyOnWriteArrayList,ConcurrentLinkedQueue等。
4.使用java.util.concurrent.atomic下多种类小工具包,使读取-更新-保存过程原子化。
5.使用同步工具类进行互斥同步,主要包括以下几种类:
a:闭锁CountDownLatch,类似游戏关卡,只用把预先设定的所有关卡都countDown()后,await()才会解除阻塞,继续执行。
b:FutureTask.get,任何完成后此方法才返回结果,未完成前处于阻塞状态。
c:信号量Semaphore,类似公共厕所的蹲位,调用acquire()占用一个蹲位后开始LS,完事后调用release()释放蹲位提裤子走人,当蹲位满时,调用acquire()想用蹲位的人必须 憋着等待有人释放,如果一直人没有释放,就一直憋着或超时拉在裤子上。多用于池化实现,如连接池。
d:栅栏Barrier,类似旅游团包车,人数一定,每个上车的人调用await等待其他的人上车,当所有旅客都上车后,最后一个上车的司机开动车子,驶向预定的目标。
当然多线程还需要考虑很多的事情,以上阐述也也只是很小很浅显的一部分,水平有限,再咧咧恐贻笑大方了。简单理解多线程同步,就是利用同步手段使多个线程按一定顺序存取变量,无非也就涉及到如何操作存取,如何执行存取,如何调度存取,因此明白了本文最开始提出的三个问题,相信大家在今后的多线程并发中不会感到茫然,起码头脑中会有基本的思路,也可以帮伪大牛们真正走向成为大牛的道路,当然多线程远不止这些,真正成为大牛还得不断的摸索实践,也欢迎大家多分享。