倒霉的菜鸟

  博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

我们通常说的保持同步,其实就是对共享资源的保护。在单线程模型中, 我们永远不用担心“多个线程试图同时使用同一个资源的问题”, 但是有了并发, 就有可能发生多个线程竞争同一个共享资源的问题。

就好比你正在餐厅里吃饭,当你拿起筷子正要夹盘子里的最后一块肉时, 这片肉突然消失了。因为你的线程被挂起了, 另一个人进入餐厅并吃掉了它。

这就是我们在多线程下需要处理的问题----我们需要某种方式来防止两个任务同时访问相同的资源

那么我们很容易想到第一种方法: 加锁, 好比我们进入卫生间之后要把门关上, 下一个人来到卫生间门口要先敲门,没人的话他就可以直接使用, 否则就要等到里面的人出来。不管你多么着急,不管外面排了多少人,没办法,只要他还在里面,那你就只能等,哪怕他在里面睡觉玩手机。。。 当他终于打开门出来的时候, 离门最近的那么人很有可能会成功进入, 但这一点并不能保证。

同理, 我们给共享资源加一把锁,任意时刻都只允许一个线程操作共享资源,当某个线程试图访问该资源时,需要先检查锁的状态,如果当前没有其他线程在使用, 则获取锁,开始操作资源,操作完成后再释放资源;否则就要排队等待。

常见的加锁方法大致有以下几种:

1, synchronized关键字修饰方法

之前在对HashMap的描述中, 我们说hsahMap是线程不安全的, 但是古老的Hashtable是线程安全的, 就是因为HashTable中对所有操作共享资源的方法都使用了synchronized关键字进行了修饰, 如下:

 

 共享资源一般是以对象形式存在的内存片段,也有可能是文件,输入/输出端口,打印机等。但是要控制对共享资源的访问, 得先把它包装进一个对象,然后把所有要访问这个资源的方法标记为synchronized. 

注意, 这里的描述是“所有”。 为什么呢?

因为java中的对象都自动含有单一的锁(也叫监视器或者对象锁), 当某个线程在对象上调用其任意synchronized方法时,此对象会被加锁(锁住的是整个对象),所以在该线程释放锁之前, 其他线程无法访问该对象内任一修饰为synchronized的方法。(其他未被synchronized修饰的方法可以被随意访问)

一个线程可以多次获得对象的锁,JVM负责跟踪对象被加锁的次数,如果锁被释放,则重置为0, 在线程第一次给对象加锁的时候,计数为1,每当这个相同的线程在对象上获得锁时(从一个synchronized方法到另一个synchronized方法),计数+1,显然只有首先获得锁的线程才可以继续获得多个锁。每离开一个synchronized方法, 计数-1。当计数为0时,锁完全释放

注意: 当我们使用synchronized保护共享资源时, 记得声明该资源为private, 防止其他线程直接访问域

java中的类也有一个锁,作为类的class对象的一部分。所以当我们用synchronized关键字修饰一个静态方法时,获取该方法的锁也意味着整个类中的static方法都会被加锁。

2, synchronized关键字锁定代码块

上文中我们说到,古老的hashtable是线程安全的,因为它在源码中对所有操作共享资源的方法都加了锁。

但是我们在日常开发中,很少会用到它。因为在某些情况下,我们其实只需要保护方法里的核心代码,为整个方法加锁会增加多线程访问下的时间成本

大家可以回顾一下单例模式中的双重锁模式

 

 为什么要判空两次?然后在第二次判空之后才加锁呢?

如果我们直接给getSingleTon方法加锁,当然也能实现同步。但带来的问题是, 如果singleTon已经被创建,应该直接返回就好了,但事实上每次线程执行到这里都要试图获取锁,这是不必要的开销。

所以第一层判断如果singleTon已经被创建,则无需获取锁直接返回。

第二层判断的意义是,想象一下,第一个线程来到这里,检查发现singleTon为空,那么它就会获得锁, 并创建了一个singleTon的实例。在它创建singleTon实例的过程中,另一个线程也来到了这里执行了第一层判断,发现singleTon为null(因为此时第一个线程还没有完成创建), 于是它排队等待,然后第一个线程创建完成之后释放锁,第二个线程进入同步块,此时如果没有第二层判空,那么它就会直接创建一个singleTon实例, 这样就有了两个实例

值得一提的是, 当我们使用synchronized同步代码块时, 需要传入一个类或者说对象,如上文中我们传入了SingleTon.class

因为synchronized快必须指定一个在其上进行同步的对象,通常最合理的方式是使用使用其方法正在被调用的当前对象,如

synchronized(this)

当我们传入this时, 如果某个线程获得了同步块中的锁, 那么当前对象中其他的synchronized方法和synchronized块都不能被其他线程调用(其实跟上文说到对象锁的一样)

当然, 如果需要的话,我们也可以在在一个对象的同步块中去同步另一个对象, 比如我们在A对象的某个方法中 synchronized(B), 那么当某个线程获得锁时, 它获得的是B的对象锁, 这也就意味着与此同时B中的synchronized方法/块不能被其他线程执行, 而A中的则不受影响。

 

3, ReentrantLock显式加锁 

java.util.concurrent类库中还包含了定义在java.util.concurrent.locks中的显式互斥机制,如ReentrantLock, 它的简单用法如下:

 

 可以看到, 我们使用lock时,必须显式地创建,锁定和释放,所以与synchronized相比,代码缺乏优雅性。但是,对于解决某些类型的问题来说,它更加灵活。

注意, 我们应当使用 try-finally语句, 确保在finally中unlock。 如果该方法有返回值, return语句应放在try中, 以确保unlock不会过早发生

当我们使用synchronized关键字时, 某些事物失败了就会抛出异常, 但我们没有机会去做一些清理的工作。 显式lock的优点就体现在这里, 我们可以在finally子句中,将系统维护在正确的状态。

4,ReentrantReadWriteLock显式加锁

ReentrantReadWriteLock实现了ReadWriteLock接口,这种锁的特点是,允许同时有多个读取者,只要它们都不试图写入就行。如果写锁已经被其他线程持有, 那么任何读取者都不能访问,直到写锁被释放。

所以,针对那些频繁读取,极少写入的情况, 使用ReentrantReadWriteLock可以提高性能。

ReentrantReadWriteLock的用法大致如下:没有仔细研究过,不保证正确

 

 

5, 使用ThreadLocal进行线程本地存储

上文说到的4种方式从本质上来说其实都是一样的, 都是通过对共享资源加锁的方式来实现同步

接下来我们保护共享资源的的另一个解决思路------根除对变量的共享

线程本地存储是一种自动化机制,可以为使用相同变量的每个不同线程创建不同的存储, 通常写法如下:

 

 注意: ThreadLocal对象通常当作静态域存储,在创建ThreadLocal时,只能通过get和set方法来访问该对象的内容。

其中value.get()方法将返回与当前线程相关联的对象的副本, 而set会将参数插入到为其线程存储的对象中, 并返回存储中原有的对象

 

6, 利用可见性实现同步-- volatile

volatile关键字确保了应用中的可视性,如果你将一个域声明为volatile, 那么只要对这个域产生了写操作,那么所有的读操作都可以看到修改,即使用了本地缓存,情况也是如此,因为volatile域会立即被写入到主存中,而读取操作就发生在主存中。

volatile是轻量级的synchronized, 它比 synchronized执行成本低因为它不需要切换上下文以及调度线程。

但是, volatile只适用于静态域,就是说只有一个线程对共享资源进行写操作,可以有多个线程执行读操作。当一个域的值依赖于它之前的值(如递增一个计数器)或者这个域的值受其他域的值限制时,volatile将无法工作。

同时volatile关键字并不保证原子性

 

7, 使用原子性操作(原子类)来保证同步

在java中,原子的意思就是不可再分,比如 return i 我们可以认为它是原子性的, 但 i++并不是原子性的

原子操作可有线程机制来保证其不可中断。一旦操作开始, 那么它一定可以在可能发生的线程切换之前被执行完毕。

因此,如果我们确定某个操作时原子性的, 那它就是线程同步的。

所以,在某些情况下,我们可以使用原子类来保证同步。如AtomicInteger, AtomicLong, AtomicReference等

这些类是在机器级别上的原子性,因此使用他们的时候,通常不用担心同步问题。

 

8, 使用SingleThreadExecutor

在上一篇文章--启动线程 中我们提到过,SingleThreadExecutor的调用会产生单线程执行器, 当我们set多个线程时, 她们将按照被提交的顺序依次执行

 

9, 免锁容器。 如Vector, Hashtable这些早期容易,使用了大量的synchronized方法来保证同步

Java SE5特别添加了一些新的容器,如CopyOnWriteArrayList, CopyOnWriteArraySet, ConcurrentHashMap

这类免锁容器背后的通用策略是: 对容器的修改可以与读取操作同时发生, 只要读取者能看到修改后的内容就行。

修改是在容器数据结构的某个副本中执行的, 并且这个副本在修改过程中不可视,修改完成后,会立即将修改后的数据与主数据结构进行交换。

值得特别说明的是, ConcurrentHashMap还引入了分段锁,将数据分成多个数据段分别加锁,从而提高并发性能。 

 

 

小结:

其实从根本上来说,上面的1,2,3,4都可归纳为加锁的方式,所以

一, 加锁 (上面的1,2, 3,4)

二,线程封闭,消除对资源的共享(5)

三, 利用可见性(6)

四,原子类(7)

五,executor框架(8)

六, 使用同步类/免锁容器(9)

 

posted on 2021-09-22 23:01  倒霉的菜鸟  阅读(3197)  评论(0编辑  收藏  举报