`
raulcsj
  • 浏览: 1691 次
  • 性别: Icon_minigender_1
  • 来自: 苏州
最近访客 更多访客>>
社区版块
存档分类
最新评论

轻松使用线程: 同步不是敌人

阅读更多
synchronized  真正意味着什么?

大部分 Java 程序员对同步的块或方法的理解是完全根据使用互斥(互斥信号量)或定义一个临界段(一个必须原子性地执行的代码块)。虽然 synchronized 的语义中确实包括互斥和原子性,但在管程进入之前和在管程退出之后发生的事情要复杂得多。

synchronized 的语义确实保证了一次只有一个线程可以访问被保护的区段,但同时还包括同步线程在主存内互相作用的规则。理解 Java 内存模型(JMM)的一个好方法就是把各个线程想像成运行在相互分离的处理器上,所有的处理器存取同一块主存空间,每个处理器有自己的缓存,但这些缓存可能并不总和主存同步。在缺少同步的情况下,JMM 会允许两个线程在同一个内存地址上看到不同的值。而当用一个管程(锁)进行同步的时候,一旦申请加了锁,JMM 就会马上要求该缓存失效,然后在它被释放前对它进行刷新(把修改过的内存位置写回主存)。不难看出为什么同步会对程序的性能影响这么大;频繁地刷新缓存代价会很大。
如果同步不适当,后果是很严重的:会造成数据混乱和争用情况,导致程序崩溃,产生不正确的结果,或者是不可预计的运行。更糟的是,这些情况可能很少发生且具有偶然性(使得问题很难被监测和重现)。如果测试环境和开发环境有很大的不同,无论是配置的不同,还是负荷的不同,都有可能使得这些问题在测试环境中根本不出现,从而得出错误的结论:我们的程序是正确的,而事实上这些问题只是还没出现而已.另一方面,不当或过度地使用同步会导致其它问题,比如性能很差和死锁。当然,性能差虽然不如数据混乱那么严重,但也是一个严重的问题,因此同样不可忽视。编写优秀的多线程程序需要使用好的运行路线,足够的同步可以使您的数据不发生混乱,但不需要滥用到去承担死锁或不必要地削弱程序性能的风险。

什么时候需要同步

要使您的程序线程安全,首先必须确定哪些数据将在线程间共享。如果正在写的数据以后可能被另一个线程读到,或者正在读的数据可能已经被另一个线程写过了,那么这些数据就是共享数据,必须进行同步存取。有些程序员可能会惊讶地发现,这些规则在简单地检查一个共享引用是否非空的时候也用得上。

争用为什么是这样一个问题


争用同步之所以慢,是因为它涉及多个线程切换和系统调用。当多个线程争用同一个管程时,JVM 将不得不维护一个等待该管程的线程队列(并且这个队列在多个处理器间必须是同步的),这就意味着花费在 JVM 或 OS 代码上的时间相对多了,而花费在程序代码上的时间则相对少了。而且,争用还削弱了可伸缩性,因为它迫使调度程序把操作序列化,即使有可用的空闲处理器也是如此。当一个线程正在执行一个同步块时,任何等待进入该块的线程都将被阻塞。如果没有其他的线程可供执行,那么处理器就将空闲。
如果想编写具可伸缩性的多线程程序,我们就必须减少对临界资源的争用。有很多技术可以做到这一点,但在应用它们之前,您需要仔细研究一下您的代码,判断出在什么情况下您需要在公共管程上同步。判断哪些锁是瓶颈很困难:有时候锁隐藏在类库中,有时候又通过同步方法隐式地指定,因此在阅读代码时,锁并不那么明显。而且,目前的争用检测工具也很差。
技术 1:放进去,取出来

使同步块尽可能小显然是降低争用可能性的一种技术。一个线程占用一个给定锁的时间越短,另一个线程在该线程仍占用锁时请求该锁的可能性就越小。因此在您应该使用同步去访问或更新共享变量时,在同步块的外面进行线程安全的预处理或后处理通常会更好些。

清单 1 演示了这种技术。我们的应用程序维护一个代表各种实体的属性的 HashMap ,给定用户的访问权列表就是这种属性中的一种。访问权被存储成一个以逗号隔开的权限列表。方法 userHasAdminAccess() 在全局属性表中查找用户的访问权,并判断该用户是否有被称为“ADMIN”的访问权。

清单 1. 在同步块中花费太多(多于必要时间)时间

  public boolean userHasAdminAccess(String userName) {
    synchronized (attributesMap) {
      String rights = attributesMap.get("users." + userName + ".accessRights");
      if (rights == null)
        return false;
      else
        return (rights.indexOf("ADMIN") >= 0);
    }
  }


这个版本的 userHasAdminAccess 是线程安全的,但它占用锁的时间比必要占用时间长太多。为创建串联的字符串 “users.brian.accessRights” ,编译器将创建一个临时的 StringBuffer 对象,调用 StringBuffer.append 三次,然后调用 StringBuffer.toString ,这意味着至少两个对象的创建和几个方法调用。接着,程序将调用 HashMap.get 检索该字符串,然后调用 String.indexOf 抽取想要的权限标识符。 就占这个方法所做的全部工作的百分比而言,预处理和后处理的比重是很大的;因为它们是线程安全的,所以将它们移到同步块外面是有意义的,如清单 2 所示。

清单 2. 减少花费在同步块中的时间

  public boolean userHasAdminAccess(String userName) {
    String key = "users." + userName + ".accessRights";
    String rights;
    synchronized (attributesMap) {
      rights = attributesMap.get(key);
    }
    return ((rights != null)
            && (rights.indexOf("ADMIN") >= 0));
  }


另一方面,有可能会过度使用这种技术。要是您想用一小块线程安全代码把要求同步的两个操作隔开,那么只使用一个同步块一般会更好些。

技术 2:减小锁的粒度

把您的同步分散在更多的锁上是减少争用的另一种有价值的技术。例如,假设您有一个类,它用两个单独的散列表来存储用户信息和服务信息,如清单 3 所示。

清单 3. 一个减小锁的粒度的机会

public class AttributesStore {
  private HashMap usersMap = new HashMap();
  private HashMap servicesMap = new HashMap();
  public synchronized void setUserInfo(String user, UserInfo userInfo) {
    usersMap.put(user, userInfo);
  }
  public synchronized UserInfo getUserInfo(String user) {
    return usersMap.get(user);
  }
  public synchronized void setServiceInfo(String service,
                                          ServiceInfo serviceInfo) {
    servicesMap.put(service, serviceInfo);
  }
  public synchronized ServiceInfo getServiceInfo(String service) {
    return servicesMap.get(service);
  }
}


这里,用户和服务数据的访问器方法是同步的,这意味着它们在 AttributesStore 对象上同步。虽然这样做是完全线程安全的,但却增加了毫无实际意义的争用可能性。如果一个线程正在执行 setUserInfo ,就不仅意味着其它线程将被锁在 setUserInfo 和 getUserInfo 外面(这是我们希望的),而且意味着它们也将被锁在 getServiceInfo 和 setServiceInfo 外面。

通过使访问器只在共享的实际对象( userMap 和 servicesMap 对象)上同步可以避免这个问题,如清单 4 所示。

清单 4. 减小锁的粒度

public class AttributesStore {
  private HashMap usersMap = new HashMap();
  private HashMap servicesMap = new HashMap();
  public void setUserInfo(String user, UserInfo userInfo) {
    synchronized(usersMap) {
      usersMap.put(user, userInfo);
    }
  }
  public UserInfo getUserInfo(String user) {
    synchronized(usersMap) {
      return usersMap.get(user);
    }
  }
  public void setServiceInfo(String service,
                             ServiceInfo serviceInfo) {
    synchronized(servicesMap) {
      servicesMap.put(service, serviceInfo);
    }
  }
  public ServiceInfo getServiceInfo(String service) {
    synchronized(servicesMap) {
      return servicesMap.get(service);
    }
  }
}


现在,访问服务 map(servicesMap)的线程将不会与试图访问用户 map(usersMap)的线程发生争用。(在这种情况下,通过使用 Collections 框架提供的同步包装机制,即 Collections.synchronizedMap 来创建 map 可以达到同样的效果。)假设对两个 map 的请求是平均分布的,那么这种技术在这种情况下将把可能的争用数目减半。


在 HashMap 中应用技术 2

服务器端的 Java 应用程序中最普通的争用瓶颈之一是 HashMap 。应用程序使用 HashMap 来高速缓存所有类别的临界共享数据(用户概要文件、会话信息、文件内容), HashMap.get 方法可能对应于许多条字节码指令。例如,如果您正在编写一个 Web 服务器,而且所有的高速缓存的页都存储在 HashMap 中,那么每个请求都将需要获得并占用那个 map 上的锁,这就将成为一个瓶颈。

我们可以扩展锁粒度技术以应付这种情形,尽管我们必须很小心,因为有与这种方法有关的一些 Java 内存模型(Java Memory Model,JMM)危害。清单 5 中的 LockPoolMap 展示了线程安全的 get() 和 put() 方法,但把同步分散在了锁池中,充分降低了争用可能性。

LockPoolMap 是线程安全的,其功能类似于简化的 HashMap ,但却有更多吸引人的争用属性。同步不是在每个 get() 或 put() 操作上对整个 map 进行,而是在散列单元(bucket)级上完成。每个 bucket 都有一个锁,而且该锁在遍历 bucket(为了读或写)的时候被获取。锁在创建 map 的时候被创建(如果不在此时创建锁,将会出现 JMM 问题。)

如果您创建了带有很多 bucket 的 LockPoolMap ,那么将有很多线程可以并发地使用该 map,同时争用的可能性也被大大降低了。然而,减少争用并不是免费的午餐。由于没有在全局锁上同步,使得将 map 作为一个整体进行操作,例如 size() 方法,变得更加困难。 size() 的实现将不得不依次获取各个 bucket 的锁,对该 bucket 中的节点进行计数,释放锁,然后继续到下一个 bucket。然而前面的锁一旦被释放,其它的线程就将可以自由修改前面的 bucket。到 size() 完成对元素的计数时,结果很可能是错的。不过, LockPoolMap 技术在某些方面还是可以做得相当好的,例如共享高速缓存。

清单 5. 减小 HashMap 上锁的粒度

import java.util.*;
/**
* LockPoolMap implements a subset of the Map interface (get, put, clear)
* and performs synchronization at the bucket level, not at the map
* level.  This reduces contention, at the cost of losing some Map
* functionality, and is well suited to simple caches.  The number of
* buckets is fixed and does not increase.
*/
public class LockPoolMap {
  private Node[] buckets;
  private Object[] locks;
  private static final class Node {
    public final Object key;
    public Object value;
    public Node next;
    public Node(Object key) { this.key = key; }
  }
  public LockPoolMap(int size) {
    buckets = new Node[size];
    locks = new Object[size];
    for (int i = 0; i < size; i++)
      locks[i] = new Object();
  }
  private final int hash(Object key) {
    int hash = key.hashCode() % buckets.length;
    if (hash < 0)
      hash *= -1;
    return hash;
  }
  public void put(Object key, Object value) {
    int hash = hash(key);
    synchronized(locks[hash]) {
      Node m;
      for (m=buckets[hash]; m != null; m=m.next) {
        if (m.key.equals(key)) {
          m.value = value;
          return;
        }
      }
      // We must not have found it, so put it at the beginning of the chain
      m = new Node(key);
      m.value = value;
      m.next = buckets[hash];
      buckets[hash] = m;
    }
  }
  public Object get(Object key) {
    int hash = hash(key);
    synchronized(locks[hash]) {
      for (Node m=buckets[hash]; m != null; m=m.next)
        if (m.key.equals(key))
          return m.value;
    }
    return null;
  }
}


表 1 比较了共享 map 的三种实现的性能:同步的 HashMap ,非同步的 HashMap (线程不安全的)和 LockPoolMap 。提供非同步的版本只是为了展示争用的开销。我们在使用 Sun 1.3 JDK 的双处理器系统 Linux 系统上,用不同数目的线程,运行了在 map 上执行随机进行 put() 和 get() 操作的测试。该表展示了每个组合的运行时间。这个测试是有点极端的一个案例,测试程序只是访问 map,而不做任何别的事,因此它比实际的程序存在多得多的争用,设计这个测试只是为了说明争用对性能的损害。

表 1. HashMap 和 LockPoolMap 之间的可伸缩性比较
线程 非同步的 HashMap (不安全的) 同步的 HashMap LockPoolMap
1.1 1.4 1.6
1.1 57.6 3.7
2.1 123.5 7.7
3.7 272.3 16.7
16 6.8 577.0 37.9
32 13.5 1233.3 80.5

虽然在线程数量很多的情况下,所有的实现都表现出相似的伸缩性特征,但 HashMap 实现在从一个线程变到两个线程时却表现出对性能影响的巨大变化,因为此时每一个 put() 和 get() 操作都存在争用。在线程数大于 1 时, LockPoolMap 技术几乎比 HashMap 技术快 15 倍。这个差别反映了调度开销上的时间损失和用于等待获取锁的空闲时间。 LockPoolMap 的优势在拥有更多处理器的系统中将表现得更加明显。

技术 3:锁崩溃

另一种能提高性能的技术称为“锁崩溃”(请参阅清单 6)。回想一下, Vector 类的方法几乎都是同步的。假设您有一个 String 值的 Vector ,并想搜索最长的 String 。进一步假设您已经知道只会在末端添加元素,而且元素不会被删除,那么,像 getLongest() 方法所展示的那样访问数据是安全的(通常),该方法只是调用 elementAt() 来检索每个元素,简单地对 Vector 的元素作循环。

getLongest2() 方法非常相似,除了在开始循环之前获取 Vector 上的锁之外。这样做的结果是当 elementAt() 试图获取锁时,JVM 将注意到当前线程已经拥有锁,而且将不会参与争用。 getLongest2() 加大了同步块,这似乎违背了“放进去,取出来”的原则,但因为避免了很大量可能的同步,调度开销的时间损失也少了,速度仍然快得多。

在运行 Sun 1.3 JDK 的双处理器 Linux 系统上,拥有两个线程,仅仅循环调用 getLongest2() 的的测试程序比调用 getLongest() 的要快 10 倍以上。虽然两个程序的序列化程度相同,但前者调度开销的时间损失要少得多。这又是一个极端的示例,但它表明争用的调度开销并不是微不足道的。即使只运行一个线程,崩溃版的速度也要快约 30% :获取您已占用的锁比获取无人占用的锁要快得多。

清单 6. 锁崩溃

  Vector v;
  ...

public String getLongest() {
    int maxLen = 0;
    String longest = null;
    for (int i=0; i<v.size(); i++) {
      String s = (String) v.elementAt(i);
      if (s.length() > maxLen) {
        maxLen = s.length();
        longest = s;
      }
    }
    return longest;
  }
  public String getLongest2() {
    int maxLen = 0;
    String longest = null;
    synchronized (v) {
      for (int i=0; i<v.size(); i++) {
        String s = (String) v.elementAt(i);
        if (s.length() > maxLen) {
          maxLen = s.length();
          longest = s;
        } 
      } 
      return longest;
    }
  }
分享到:
评论

相关推荐

    concurrent 多线程 教材

    15 轻松使用线程 同步不是敌人.mht 16 轻松使用线程 减少争用.mht 17 轻松使用线程 不共享有时是最好的.mht 18 适用于 Java 程序员的 CSP,第 1 部分.mht 19 适用于 Java 程序员的 CSP ,第 2 部分.mht 20 适用...

    space-shooter:一个简单的Java射击游戏

    2. 线程同步:确保游戏状态的更新在多个线程间正确同步,防止数据冲突。 五、资源管理 1. 图片、音频和纹理:游戏中的图像、声音和纹理需要被加载和管理,Java提供了多种方法来处理这些资源,如使用InputStream和...

    C#坦克大战游戏代码

    8. **多线程编程**:为了实现更复杂的游戏效果,如背景音乐播放、动画同步,开发者可能需要使用多线程来分离不同的任务。 9. **调试技巧**:Visual Studio提供了强大的调试工具,如断点、变量监视、调用堆栈等,...

    java飞机小游戏(是男人就坚持xx秒)

    3. **多线程**:为了实现流畅的游戏体验,通常会使用两个线程——一个负责游戏逻辑更新(如敌机移动、子弹飞行),另一个负责渲染界面。这种方式称为模型-视图-控制器(MVC)架构,保证了游戏逻辑与显示的同步。 4....

    VC++分布式坦克大战游戏完整版.zip.zip

    5. **多线程编程**:为了提高游戏性能,往往需要使用多线程技术,比如将游戏逻辑和渲染分开到不同的线程执行,避免阻塞主线程。C++的std::thread库可以帮助开发者管理线程,确保游戏运行流畅。 6. **游戏逻辑与物理...

    坦克大战完整源代码

    5. **多线程编程**:虽然"坦克大战"可能没有涉及复杂的多线程,但在某些情况下,例如处理网络同步或AI运算时,多线程技术可能会被应用到。 6. **文件操作**:游戏地图、游戏数据可能存储在文件中,需要读取和写入。...

    C# 游戏 仿炸弹人

    8. **性能优化**:为了提供流畅的游戏体验,开发者需要关注代码性能,如避免不必要的计算,使用合适的数据结构和算法,以及利用多线程提高游戏运行效率。 9. **调试与测试**:项目中提到可能存在不足之处,调试和...

    游戏编程C++

    4. **多线程编程**:游戏通常需要同时处理多个任务,如动画更新、AI计算、网络通信等,掌握多线程编程技巧,可以显著提升游戏性能。 5. **资源管理**:游戏中的纹理、模型、音效等资源管理是一项挑战,学习如何使用...

    基于 qt linux 小游戏 2个哦

    9. **网络编程**:对于多玩家游戏,可能需要使用QT的网络模块实现数据的交换和同步。 10. **调试工具**:QT Creator集成了强大的调试工具,可以帮助开发者找出程序中的错误和性能瓶颈。 通过以上知识点的运用,...

    Java2游戏程序设计实例

    在游戏开发中,OOP允许我们设计复杂的游戏对象和结构,异常处理确保程序的稳定性和健壮性,多线程技术则可以实现游戏的并行处理,如背景音乐播放与游戏逻辑的同步进行,而GUI则是游戏画面的呈现基础。 在Java2游戏...

    Unity 3D飞机大战完整源码.zip

    Unity 3D飞机大战是一款基于Unity 3D...此外,通过阅读和分析源代码,你可以学习到如何设计游戏架构、优化性能以及处理多线程和网络同步等问题。这是一个宝贵的实践和学习机会,有助于提升你在3D游戏开发领域的技能。

    C#开发的四国军棋网络版

    2. **网络编程**:利用C#的System.Net命名空间,开发者可以轻松实现网络通信,包括TCP/IP协议的使用,确保数据在网络中的可靠传输,保证游戏的同步性。 3. **多线程处理**:在网络游戏中,为了保证用户体验,通常...

    坦克大战源代码

    同时,Java的并发工具如synchronized关键字和wait/notify机制,可以确保线程间的同步和通信,避免数据竞争问题。 游戏的图形界面通常使用Java的Swing或JavaFX库来构建。这些库提供了丰富的组件和方法,使得开发者...

    框架学习gameframe框架学习

    Gameframe可能提供了网络同步、数据包封装解封装、服务器和客户端的交互模型等,帮助开发者轻松处理多人游戏的网络问题。 5. **物理引擎集成**:Unity内置了基于物理的引擎,Gameframe框架可能会提供与物理系统的...

    PyPI 官网下载 | Tower_defence_Golear_Karpenko-0.2.tar.gz

    3. **分布式协调**:利用Zookeeper进行节点间的通信和状态同步,确保在分布式环境中游戏状态的一致性。 4. **资源管理**:可能包含对游戏资源(如图像、音频)的加载和优化,以便在云环境中高效利用资源。 5. **...

    JungleWar:堡垒撕裂

    这些库允许开发者轻松地实现状态同步,确保游戏的公平性和一致性。 此外,C#的泛型、枚举、接口等功能也在游戏中有广泛应用。泛型可以提供更灵活的数据类型处理,枚举用于定义游戏中的常量(如游戏状态、角色类型)...

    飞机大战小游戏(java).zip

    例如,一个单独的线程负责更新游戏状态,而另一个线程则处理用户输入,确保两者之间的同步。此外,Java集合框架如ArrayList和HashMap可以用来管理游戏对象的状态,如飞机、子弹和敌人。 "PlaneWar-master"这个...

    carpglib:CaRpg游戏使用的引擎

    例如,通过创建自定义的游戏对象,继承carpglib的基类,可以轻松实现角色、敌人、物品等实体的行为。同时,通过事件驱动的架构,开发者可以响应玩家的输入,更新游戏状态,进行碰撞检测和反应。 在使用carpglib时,...

    Momu:兔子人

    7. **多线程**:为了实现流畅的游戏体验,可能会用到多线程技术来处理非UI计算任务,如音频播放、网络通信等。 8. **网络编程**:如果游戏包含多人在线功能,C#的网络编程能力就显得尤为重要,它可以帮助开发者实现...

    50个统一提示:从编辑器工具到序列化再到UI快捷方式的有关Unity(专注于Mobile)的50个提示的集合

    22. **UI布局**:使用Canvas Scaler和Layout Group组件,轻松创建适应不同屏幕尺寸的UI。 23. **性能分析**:使用Profiler分析CPU和GPU性能,关注Draw Calls、Allocations和GPU Time等关键指标。 24. **优化纹理**...

Global site tag (gtag.js) - Google Analytics