转载

Java并发编程:5-线程安全

前言:

上一篇介绍了数据安全性问题,本篇再谈一谈多线程带来的活跃性问题及性能问题,最后介绍一些常用来保证线程安全的策略。

面试问题

Q :什么是死锁,如何避免死锁?

Q :如何保证线程安全?

1.活跃性问题

1.1 死锁

死锁:一组相互竞争资源的线程因相互等待,导致“永久”阻塞的现象。

这里就不得不说描述死锁的经典例示例——“哲学家进餐”问题,五个哲学家去吃中餐,围坐在一张圆桌旁,他们每个人只有一根筷子,并且放在两个人的中间。哲学家们时而思考,时而吃饭。每个人都需要一双筷子才能吃东西,吃两口后把筷子返回远处,继续思考。

正常情况下五个人不会同时去吃,也就不会出现问题,但如果五个人同时准备吃饭,并且,同时先拿起了他们左手边的筷子,准备去拿他们右手边的筷子,这个时候发现每个人的右手边筷子都被右边的人拿了,大家都在等待右边的人把筷子放下,形成了循环等待,没有一个人可以吃饭,这就形成了一个死锁。

产生死锁的4个条件:

  • 互斥条件 :使用的资源无法共享,任意一个时刻只由一个线程占用。一根筷子每次只能被一个人用。
  • 请求与保持 :至少有一个任务必须持有资源且正在等待获取一个被别的任务持有的资源。拿着左手的筷子,等待右手的。
  • 不可抢占 :线程已获得的资源在末使用完之前不能被其他线程强行剥夺,只有吃完才放筷子,不然一直等,只有自己使用完毕后才释放资源。不能去抢别人的筷子,只能等别人主动把筷子放下。
  • 循环等待 :A等待B、B等待A。每个人都等右边的人先放筷子,形成了一个循环等待圈。

如何避免线程死锁?

  • 破坏互斥条件 :这个条件我们没有办法破坏,因为临界资源需要互斥访问,为了达到互斥才加锁的。
  • 破坏请求与保持条件 : 一次性申请所有的资源,但这样的做法极大的降低资源利用率。把筷子都放桌子中央,需要吃饭的人一次拿两根。筷子不够两根的话,进行等待。
  • 破坏不可抢占条件 :占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。如果拿不到右手的筷子,就把左手的筷子也放下,这种方式需要手动去释放锁,而synchrnized会自动加锁和释放锁,无法手动去释放,J.U.C中的Lock可以很好的解决这个问题。
  • 破坏循环等待条件 :依靠按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。前四个人都是先拿左再拿右,可以让最后一个人先拿右再拿左,如果将筷子放在桌子中央可以对筷子进行标号1到5,从小到大申请顺序,这样就不会发生循环等待了。

1.2 活锁

活锁是另一种活跃性问题,该问题尽管不会阻塞线程,但也不能继续执行任务。例如两个互相协作的线程都修改对方的状态,就像两个过于礼貌的人在半路上面对面的相遇,他们彼此给对方让路,然后又在另一条路上相遇了,于是就陷入了这种反复避让的循环中,谁也无法通过。

解决的方案很简单,只需要在重试机制中加入随机性,谦让时等待一个随机时间,这样再次相撞的概率就很低了。

1.3 饥饿

饥饿则是指线程因无法及时访问所需资源,这里的资源包括CPU时钟周期及需要互斥访问的共享资源,之前在介绍Thread的时候,其 priority 代表的就是线程的优先级,优先级高的更容易分配的CPU的执行权。同理,优先级低的不容易分配到CPU的执行权,如果CPU繁忙的情况下,优先级低的线程拿不到执行权,就可能发生线程“饥饿”,持有锁的线程,执行时间过长,也可能导致“饥饿”问题。

Thread API中定义的优先级只能作为线程调度的参考,JVM会根据Thread类的10个优先级映射到操作系统的调度优先级,这种映射可能是不对等的,不同操作系统的优先级个数可能不同,但肯定有最高、最低和正常这三个标准,这也就是Thread类中只定义了三种优先级(MIN_PRIORITY、NORM_PRIORITY和MAX_PRIORITY)的原因。

因此避免使用线程优先级来支持程序的线程安全,这会增加平台依赖性,且可能会导致活跃性问题,大部分情况下,使用默认的线程优先级即可。

解决“饥饿”问题也很简单,三种方案:一是保证资源充足;二是避免持有锁的线程长时间执行;三是公平的分配资源,这里主要是使用公平锁,先来排队等待的线程优先获取资源。第三点使用公平锁公平分配资源会在下一篇J.U.C中进行介绍。

2.性能问题

2.1 上下文切换

可运行的线程数大于CPU数量,那么操作系统会将某个正在执行的线程调度出来,从而使其他线程能够使用CPU,这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文。

当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起,并允许它被调度出去,程序中发生的阻塞(包括阻塞I/O、等待获取发生竞争的锁、或者在条件变量上等待)越多,CPU密集型的程序发生的上下文切换也就越多,从而增加调度开销,降低了系统的吞吐量。

上下文切换的开销会随平台不同而变化,大多数通用的处理器中,上下文切换的开箱相当于5000~10000个时钟周期。

2.2 内存同步

之前提到volatile和synchronized的可见性保证会使用一些特殊指令,即内存栅栏,内存栅栏可以刷新缓存,使缓存无效。还会禁止一些编译器优化操作,在内存栅栏中,大多数操作都是不能被重排序的。

2.3 阻塞

锁竞争失败会阻塞,JVM在实现阻塞行为的时候,可以通过自旋锁的方式(不把线程挂起,通过不断循环尝试获取锁,直到成功)或者通过操作系统挂起被阻塞的线程。这两种方式的效率高下,取决于上下文的开销和成功获取锁前需要等待的时间,如果等待时间小于上下文切换的时间开销(5k~10k个时钟周期),则适合采用自旋的方式,反之,则适合采用挂起的方式。有些JVM会根据历史等待时间的数据分析在两者之间选择,大多数JVM在等待锁时都只是将线程挂起。

当线程无法获取某个锁或者是由于某个条件等待或是I/O操作上阻塞时,需要被挂起,在这个过程中将包含两次额外的上下文切换,以及必要的操作系统操作和缓存操作。

3.线程安全

我们了解了使用多线程可能带来的问题(数据安全性问题、活跃性问题、性能问题),这时候再来理解线程安全这个概念。程序不会发生前面讲过的这些问题,那么线程就是安全的,网上可以搜到很多不同的线程安全的定义,本文就不做具体的说明了。下面我们来聊聊保证线程安全的常用策略(这里主要针对数据安全性问题)。

数据安全性问题都是由成员变量及静态变量引起。若每个线程中对成员变量、静态变量只有读操作,而无写操作,一般来说,这是线程安全的;若有多个线程同时执行读写操作,一般都需要考虑线程同步,否则就可能影响线程安全。

3.1 加锁机制

加锁机制就是一种同步方式,synchronized 同步代码块或者同步方法可以确保以原子的方式执行操作 。由于加锁的保护,多个线程会以串行的方式来执行,就像排队一样,这样线程在使用对象的时候,对象的状态不会被另一个线程修改。synchronized不止用于实现原子性,同时还可以确保内存可见性。

Java中的同步容器,Hashtable、Vector采用的就是这种方式,但由于串行操作,大大降低了容器的并发性能,所以Java又推出了并发容器,ConcurrentHashMap、CopyOnWriteArrayList等14个并发容器 。

synchronized是Java提供的内置锁,JDK5中的JUC还提供了另外一种更灵活的加锁方式,Lock接口。

3.2 保证可见性

内存可见性,变量声明volatile后,编译器与运行时都会注意到这个变量是共享的 ,因此不会将该变量上的操作与其他内存操作一起重排序,并且volatile变量不会缓存到内存堆中的值到线程内部,而是直接修改内存中的值。

synchronzied是不允许在一个线程修改的共享变量的时候有另外一个线程也对共享变量进行修改,volatile则是允许这种修改,但是通过直接访问内存来获取共享变量的方式,使其他线程能够看到已发生的状态变化。因此volatile是比synchronized关键字更轻量级的同步机制,但是,volatile无法保证操作的原子性,可能保证可见性和有序性。

3.3 线程封闭

既然共享变量会发生线程安全问题,那么通过不共享的方式来操作,就避免同步带来的效率低下,这种方式相当于在单线程内访问各自的数据,不会和其他线程共享,所以没有并发问题。

就像调用对象的方法时,会为每次调用创建新的栈帧,而局部变量保存在各自的栈帧中。各个栈帧之间的局部变量修改相互不受影响,线程封闭也是采用这种思路,为每个线程创建各自的成员变量,这样对各自的成员变量做修改,也不会影响其他线程的值。因为不对数据共享,所以这种方式的应用场景还是有一定局限性的,但在对应的场景下,性能会非常好。

采用线程封闭技术的案例非常多,例如数据库连接池通过线程封闭技术,保证一个Connection一旦被一个线程获取之后,在这个线程关闭Connection之前的这段时间里,不会再分配给其他线程,从而保证了Connection不会有并发问题。

ThreadLocal也采用的这种技术。

3.4 实例封闭

将线程不安全的类封装在线程安全的类中,以达到安全访问数据的目的。

面向对象的三个特性,封装、继承、多态,其中封装可以简化线程安全类的实现过程,提供了一种实例封闭机制。实例封闭是构建线程安全类的一个最简单方式,它还使得在锁策略的选择上拥有更多的灵活性。

Java类库中的Collections.synchronizedList及类似方法,通过“装饰器”模式,将非线程安全的容器封装在一个同步的包装器对象中,这个包装器对象将容器实现的接口的方法实现为同步方法,将调用请求转发到底层的非线程安全的容器对象上,只要包装器对象拥有对底层容器对象的唯一引用。那么它就是线程安全的。

Reference

  《Java 并发编程实战》

  《Java 编程思想(第4版)》

   https://time.geekbang.org/col...

感谢阅读!

原文  https://segmentfault.com/a/1190000021297655
正文到此结束
Loading...