【多线程编程最佳实践】:掌握ConcurrentHashMap,提升并发效率
发布时间: 2024-10-22 04:49:44 阅读量: 1 订阅数: 9
![【多线程编程最佳实践】:掌握ConcurrentHashMap,提升并发效率](https://dz2cdn1.dzone.com/storage/temp/15570003-1642900464392.png)
# 1. 多线程编程与并发概述
多线程编程是现代软件开发中的关键技术之一,它允许程序同时执行多个任务,提高程序的响应性和吞吐量。并发是指两个或多个事件在同一时间间隔内发生,这在多线程环境下尤为重要。理解多线程和并发的基础概念是构建高效、稳定应用的基石。
在本章中,我们将首先介绍多线程编程的基本概念,包括线程的创建、管理和调度,以及它们是如何在现代操作系统中实现的。接着,我们将深入探讨并发的挑战,例如线程安全和竞态条件,这些是导致程序出错的常见原因。最后,我们将对并发编程的利弊进行分析,并简要介绍为应对并发编程带来的复杂性而出现的高级并发控制工具。
理解多线程和并发不仅对程序员的日常工作至关重要,而且对于设计健壮的系统架构和性能优化也同样重要。本章内容将为读者奠定一个坚实的理论基础,为后续深入研究如ConcurrentHashMap等并发集合类做好准备。
# 2. 深入理解ConcurrentHashMap
## 2.1 ConcurrentHashMap的工作原理
### 2.1.1 分段锁机制
在并发编程领域,`ConcurrentHashMap` 是 Java 中最常使用的并发集合之一。它解决了早期 `HashMap` 在多线程环境下存在的线程安全问题,同时提高了并发性能。`ConcurrentHashMap` 的关键特性之一就是分段锁机制,也被称为锁分段技术。
在 `ConcurrentHashMap` 的内部,数据结构被划分为多个段(segment),每个段都有自己的独立锁,这样就可以允许多个操作同时进行,而不会相互干扰。这种设计减少了锁的竞争,从而提高了并发访问的效率。
在 Java 8 及以后的版本中,虽然锁分段的概念仍然存在,但实际的实现方式已经发生了变化。不再使用单独的 Segment 类,而是通过内置的 `ConcurrentHashMap.Node` 数组进行分段,通过计算哈希值来决定数据应当放在哪个桶(bucket)中。每个桶可以视为一个小型的哈希表,内部使用链表或红黑树存储数据。
### 2.1.2 哈希表与链表的结合
`ConcurrentHashMap` 在设计上利用了哈希表的基本原理。当存储元素时,通过哈希函数计算出一个值,然后将元素存储在根据该值计算得到的索引位置。为了避免哈希冲突,每个索引位置通常采用链表结构来处理冲突的数据。
在 Java 8 中,`ConcurrentHashMap` 的实现中引入了红黑树来优化链表过长时的查找效率。当链表长度超过一定阈值(默认为 8)时,链表会转换为红黑树。这种结构的切换是为了在哈希冲突较多时,仍能保持较高的查找效率。当链表长度降到阈值以下时,又会从红黑树退化回链表,以节省空间。
## 2.2 ConcurrentHashMap的关键特性
### 2.2.1 无锁编程的实践
无锁编程是指在多线程环境中,尽量避免使用传统的锁机制来保证线程安全,而是利用现代硬件提供的原子操作指令集来实现线程安全。`ConcurrentHashMap` 在 Java 8 中就采用了无锁编程的一些技巧,尤其是在处理单个节点的操作上,大大减少了锁的使用。
在实现上,`ConcurrentHashMap` 主要使用了 `compareAndSet` 方法,这个方法底层依赖于 `Unsafe` 类的 `compareAndSwap` 操作,这是一个典型的 CAS(Compare-And-Swap)操作。CAS 是一种硬件级别的原子操作,能够保证在比较和交换两个值的时候,不会被其他线程打断。因此,在对单个节点进行操作时,`ConcurrentHashMap` 往往不需要进行加锁,这样就降低了并发操作时的开销。
### 2.2.2 高效的并发读写
`ConcurrentHashMap` 除了保证线程安全外,还通过多种手段优化了读写性能。在读操作中,由于大多数情况不需要获取锁,所以读操作能够快速进行,不会被写操作阻塞。同时,写操作在涉及到结构变更(如扩容)时,会尽量减少结构变动的影响范围,避免长时间持有全局锁。
读写操作的高效还依赖于其内部的并发级别设置(concurrencyLevel)。`ConcurrentHashMap` 允许用户指定这个级别来创建集合实例,这个值决定了内部分段的数量。理论上,`concurrencyLevel` 的值越大,并发处理的能力越强,但同时也会带来更多的内存开销。
## 2.3 ConcurrentHashMap与HashMap的对比
### 2.3.1 性能测试分析
性能测试是评估 `ConcurrentHashMap` 相对于 `HashMap` 在并发环境下表现的重要手段。通过性能测试,我们可以比较两者在不同并发级别下的读写性能。
在测试中,通常会使用 JMH(Java Microbenchmark Harness)等工具来创建基准测试,通过多次迭代来获取稳定的性能指标。测试时应考虑不同的操作类型,如插入、删除、更新和读取,以及它们在高并发情况下的表现。
测试结果通常显示,`ConcurrentHashMap` 在写操作上由于减少了锁的使用,相比于 `HashMap`,在多线程环境下有更好的性能表现。在读操作上,`ConcurrentHashMap` 由于其无锁编程实践,读取操作的速度通常更快。
### 2.3.2 使用场景的差异比较
虽然 `ConcurrentHashMap` 在并发环境下有出色的表现,但这并不意味着它总是优于 `HashMap`。在单线程环境中,`ConcurrentHashMap` 的复杂性导致它的性能可能不如 `HashMap`。因此,合理选择数据结构的关键是根据实际应用场景来决定。
当应用中涉及到高并发读写操作时,尤其是需要保证线程安全的情况下,`ConcurrentHashMap` 是一个更合适的选择。相反,如果应用大多数情况下是在单线程环境中访问,并且对读写性能的绝对速度有较高要求,那么 `HashMap` 可能更加适用。
在选择集合类型时,开发者需要考虑集合的使用频率、操作类型以及预期的并发程度。通过这些因素的综合考量,可以作出更为合理的决策。
接下来,我们将继续深入探讨 `ConcurrentHashMap` 的高级应用,以及在多线程编程中遇到的常见问题和解决方案。
# 3. ConcurrentHashMap的高级应用
在现代多线程编程中,`ConcurrentHashMap`是一个非常重要的并发集合,它提供了一种线程安全的方式来存储键值对映射,而不需要使用重量级的锁机制。本章将深入探讨`ConcurrentHashMap`的高级应用,包括迭代器的使用、原子操作以及映射与转换的技巧。
## 3.1 并发集合的迭代器
在并发编程中,迭代器是遍历集合的重要工具。然而,在多线程环境下,普通的迭代器可能无法安全使用,因为它可能会在迭代过程中遇到并发修改异常。`ConcurrentHashMap`提供了一种弱一致性的迭代器,它可以反映集合在迭代器创建之后的任何更改。
### 3.1.1 弱一致性与快速失败
弱一致性的迭代器不会抛出`ConcurrentModificationException`异常,即使在迭代过程中集合被修改了。它允许在迭代过程中进行元素的读取、删除和插入操作,但迭代器不会保证返回集合中最新的元素。
```java
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("one", 1);
map.put("two", 2);
ConcurrentHashMap迭代器遍历
Iterator<String> iterator = map.keySet().iterator();
while (iterator.hasNext()) {
String key = iterator.next();
Integer value = map.get(key);
// 在迭代过程中进行修改操作
map.remove(key);
System.out.println("Key: " + key + ", Value: " + value);
}
```
在上述代码中,即使我们使用迭代器遍历`ConcurrentHashMap`的同时进行了删除操作,程序也不会抛出异常。这是因为迭代器在遍历时只保证了弱一致性。
### 3.1.2 并发集合的遍历技巧
在遍历`ConcurrentHashMap`时,可以使用`forEach`方法来进行迭代。这个方法是Java 8引入的,可以提供更简洁的遍历语法。它内部实现了快速失败机制,能够在遍历过程中快速响应结构变化。
```java
map.forEach((key, value) -> System.out.println("Key: " + key + ", Value: " + value));
```
上述`forEach`代码块中的Lambda表达式将会被应用到`ConcurrentHashMap`的每个键值对上。如果在`forEach`执行过程中,集合中的元素被修改,那么这些修改将反映在输出中,或者可能会导致`ConcurrentModificationException`异常,这取决于具体的实现。
## 3.2 并发集合的原子操作
`ConcurrentHashMap`还支持一系列的原子操作,这些操作无需额外的同步,能够在多线程环境下保证数据的一致性和线程的安全性。
### 3.2.1 原子更新方法
在`ConcurrentHashMap`中,可以通过`putIfAbsent`等原子方法来进行条件更新。例如,如果想要确保某个键不存在映射时,才添加到映射中。
```java
Integer previousValue = map.putIfAbsent("three", 3);
if (previousValue == null) {
System.out.println("键 'three' 不存在,已添加新映射");
} else {
System.out.println("键 'three' 已存在,映射值为: " + previousValue);
}
```
### 3.2.2 高级原子类的使用场景
除了`ConcurrentHashMap`自身的原子操作方法之外,Java提供的`AtomicInteger`, `AtomicLong`以及`AtomicReference`等高级原子类也可以用于更复杂的场景中。这些类提供了更多高级的原子操作,例如递增、递减、比较并交换等。
```java
AtomicInteger count = new AtomicInteger(0);
// 增加计数
int c1 = count.incrementAndGet();
// 获取当前值
int c2 = count.get();
System.out.println("当前计数: " + c2);
```
在这个例子中,`incrementAndGet`方法是一个原子操作,它会原子性地将计数器的值递增,并返回新的值。
## 3.3 并发集合的映射与转换
`ConcurrentHashMap`不仅提供了高效的数据存储和访问,还可以通过一些高级API进行映射和转换操作,这对于流式处理和并行计算来说是非常有用的。
### 3.3.1 并发映射的创建与管理
通过`ConcurrentHashMap`的构造器和`newKeySet`方法,可以创建并发映射并进行管理。`newKeySet`方法提供了一种创建新映射的方法,这个映射包含的键集在并发环境下是线程安全的。
```java
ConcurrentMap<String, String> concurrentMap = new ConcurrentHashMap<>();
Set<String> keySet = concurrentMap.newKeySet();
// 并发地添加键
keySet.add("four");
keySet.add("five");
```
在上述代码中,`newKeySet`返回了一个线程安全的`Set`,可以在多线程环境中安全地添加元素。
### 3.3.2 流式操作与并行处理
在Java 8及以上版本中,`ConcurrentHashMap`支持流式操作和并行处理。流式操作可以让我们更加方便地进行数据处理,而并行处理则可以让这些操作更加高效。
```java
concurrentMap.entrySet().parallelStream()
.filter(entry -> entry.getValue() != null && entry.getValue().length() > 0)
.forEach(entry -> System.out.println(entry.getKey() + ": " + entry.getValue()));
```
在这个例子中,使用`parallelStream`对`ConcurrentHashMap`的条目进行并行处理。`filter`方法用于筛选出值不为空且长度大于0的条目,然后通过`forEach`方法进行输出。
`ConcurrentHashMap`的高级应用不止于此。在实际开发中,我们需要根据具体的应用场景来选择合适的方法和模式,以实现高性能和线程安全的并发处理。本章仅提供了一些基本的用法和技巧,更多高级的使用方法需要开发者在实践过程中探索和掌握。
# 4. 多线程编程中的常见问题与解决方案
随着应用程序越来越复杂,多线程编程已成为软件开发中不可或缺的一部分。然而,随之而来的线程安全问题、线程池的最佳实践和并发性能调优等问题也给开发者带来了极大的挑战。本章将深入探讨这些问题,并提供有效的解决方案。
## 4.1 线程安全问题
在多线程环境中,共享资源的访问往往会导致线程安全问题。理解这些问题的本质及防御措施,对于开发高效且稳定的并发应用程序至关重要。
### 4.1.1 竞态条件及其防御
竞态条件是指多个线程同时对同一数据进行读写操作时,最终结果取决于各个线程的执行顺序,这可能导致数据不一致的问题。例如,两个线程同时对一个账户的余额进行加钱操作,如果处理不当,可能会导致一些资金的丢失。
为了避免竞态条件,可以采取以下措施:
- 使用互斥锁或读写锁来控制对共享资源的访问。
- 利用原子操作来保证操作的原子性,如 `AtomicInteger` 或 `AtomicLong` 类型。
- 在设计数据结构时,采用不可变对象来减少锁的需求。
### 4.1.2 死锁的识别与预防
死锁是指两个或多个线程在执行过程中,因争夺资源而造成的一种僵局。如果程序中有死锁存在,将可能导致程序无法正常运行。
为了避免死锁,可以考虑以下策略:
- 资源分配时,确保线程一次只申请所有需要的资源。
- 实现资源的有序分配策略,例如定义资源的优先级顺序。
- 使用超时机制检测死锁,当线程在指定时间内无法获取资源时,释放已占有的资源并重试。
## 4.2 线程池的最佳实践
线程池是管理线程生命周期的有效工具,合理的配置和优化线程池,可以显著提高应用程序的性能。
### 4.2.1 线程池的设计原理
线程池的目的是为了减少创建和销毁线程的开销,以及提高资源的利用率。它通过复用一组线程来执行多个任务,从而避免了线程生命周期的重复开销。
线程池的主要组成包括:
- 一组工作线程;
- 任务队列,用于存放等待执行的任务;
- 一个执行器,负责将任务分配给工作线程执行。
### 4.2.2 线程池的配置与优化
正确的配置线程池参数对于性能调优至关重要。线程池参数主要包括核心线程数、最大线程数、任务队列、线程工厂和拒绝执行处理器。
- 核心线程数应根据程序的并发需求来确定,以保证常用的线程数量稳定。
- 最大线程数通常根据系统资源情况以及任务的性质来设定。
- 任务队列的大小和类型需要根据应用的任务特性进行选择。
- 线程工厂用于自定义线程的创建方式,如线程名称、优先级等。
- 拒绝执行处理器定义了当线程池无法处理新任务时的应对策略。
## 4.3 并发性能调优
性能调优是多线程编程中一个持续的过程,需要不断地监控和评估系统表现,并据此进行调整。
### 4.3.1 性能评估与监控
在性能评估阶段,需要收集和分析数据,了解程序在并发环境中的表现。性能评估可以包括CPU利用率、内存使用情况、线程数量变化等指标。
常用的性能监控工具有:
- JConsole、VisualVM等JVM自带的监控工具;
- Java Flight Recorder等高级分析工具;
- 专业的APM (Application Performance Management) 工具,如New Relic、AppDynamics等。
### 4.3.2 并发级别与CPU资源利用
并发级别是指在一定时间内,应用程序所能处理的最大请求数。CPU资源利用是指程序对CPU计算能力的使用效率。
合理的并发级别配置可以最大化CPU资源的利用率。如果并发级别过低,那么CPU的计算资源不能得到充分利用;相反,如果并发级别过高,可能会导致上下文切换过于频繁,反而降低了性能。
调整并发级别的策略包括:
- 使用线程池或Executor框架来管理线程;
- 根据CPU核心数、任务类型等因素调整线程池参数;
- 通过监控工具动态调整线程池的大小。
通过上述策略的优化实施,多线程编程中的常见问题可以得到有效地解决,从而提高程序的性能和稳定性。
# 5. 实践案例分析:提升应用并发效率
## 5.1 应用场景分析
### 5.1.1 高并发Web应用的挑战
高并发Web应用在处理大量用户请求时面临的最大挑战之一是维持系统的响应时间和吞吐量。随着并发用户数量的增长,服务器的负载会迅速增加,进而可能导致服务延迟和系统不稳定。为了应对这些挑战,开发者需要采用优化的技术和策略来提升应用的并发效率。
在Web应用中,提高并发性能的关键在于合理使用服务器资源,优化请求处理流程,以及实现高效的数据读写机制。例如,使用负载均衡器分散请求到多个服务器上,利用缓存来减少数据库的直接读写,以及采用无阻塞I/O模型来提升并发连接能力。
### 5.1.2 大数据处理的并发需求
大数据处理涉及的数据量庞大,不仅要求在处理数据时具备较高的并行性,还要求在数据存储和查询时也能保持高效的并发访问。为了满足大数据的并发需求,开发者通常会采用分布式计算框架和存储解决方案,比如Hadoop或Spark。
在大数据场景中,并发效率的提升往往伴随着对资源的合理分配和调度。例如,通过调整MapReduce作业的分区大小和并行度,或者优化Spark的任务分配逻辑,来提高计算资源的利用率。同时,使用内存计算(如Spark的RDDs)来减少对磁盘I/O的依赖,可以进一步提升数据处理的效率。
## 5.2 ConcurrentHashMap的实战应用
### 5.2.1 构建高性能缓存系统
在构建高性能缓存系统时,ConcurrentHashMap扮演着至关重要的角色。与传统的HashMap相比,ConcurrentHashMap具备更好的并发性能,因为它通过分段锁的机制减少了锁的竞争,从而允许更多的线程同时对不同的段进行操作。
使用ConcurrentHashMap作为缓存系统的核心组件,开发者可以享受到以下优势:
- **线程安全**:即使在多线程环境下,也能保证数据的一致性和完整性。
- **高并发读写**:通过减少锁的粒度,支持大量并发的读写操作。
- **灵活的扩展性**:易于集成到现有的系统架构中,无需对系统架构做大规模的改动。
一个简单的使用示例如下代码块所示:
```java
ConcurrentHashMap<String, Object> cacheMap = new ConcurrentHashMap<>();
cacheMap.put("cacheKey", "cachedValue");
Object value = cacheMap.get("cacheKey");
```
在这个例子中,我们创建了一个ConcurrentHashMap实例,将一些数据存储到缓存中,然后检索这些数据。由于ConcurrentHashMap的线程安全特性,这个过程可以在多线程的环境下安全地执行。
### 5.2.2 分布式环境下的应用
在分布式环境下,保持数据的一致性和系统的可用性是非常具有挑战性的。ConcurrentHashMap可以作为分布式缓存中的本地存储机制,但它并不直接提供分布式系统所需的跨节点通信、数据复制和故障转移功能。
为了在分布式环境中使用ConcurrentHashMap,开发者通常会结合分布式缓存系统,例如Redis或Hazelcast。这些系统提供了ConcurrentHashMap不具备的功能,如自动数据分片、故障恢复和弹性伸缩。
分布式缓存的一个常见应用场景是缓存数据库查询结果以减少数据库的压力。例如,在Web应用中,经常需要查询的热点数据可以通过分布式缓存来实现快速访问。
## 5.3 案例研究:从单线程到多线程的演进
### 5.3.1 代码重构与并发优化
许多现有的应用都是最初设计为单线程的,随着用户数量的增长和业务需求的扩展,这些应用需要转变为支持多线程并发处理,以提升性能和响应速度。在重构现有代码和进行并发优化时,开发者应遵循一些关键步骤:
1. **识别瓶颈**:确定应用中的性能瓶颈,如I/O操作、数据库查询、数据处理等。
2. **并发化设计**:在不影响业务逻辑的前提下,将瓶颈操作封装成独立的任务,并发执行。
3. **线程池管理**:合理配置线程池的大小,避免资源的过度消耗或浪费。
4. **线程安全**:确保共享资源的访问是线程安全的,使用锁机制或并发集合来控制访问。
5. **性能测试**:在重构后进行详细的性能测试,确保优化后的系统表现符合预期。
重构代码的一个简单示例如下:
```java
// 原始的单线程处理方法
void processSequentially(List<InputData> dataList) {
for (InputData data : dataList) {
process(data);
}
}
// 重构后的并行处理方法
void processConcurrently(List<InputData> dataList) {
ExecutorService executor = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
for (final InputData data : dataList) {
executor.submit(() -> process(data));
}
executor.shutdown();
}
```
在这个例子中,`processConcurrently`方法通过创建一个线程池来并行处理数据,这比单线程的`processSequentially`方法有潜在的性能提升。
### 5.3.2 性能对比与总结
在完成代码重构和并发优化之后,我们需要对比优化前后的性能,以验证改进的效果。性能对比应包括以下几个方面:
- **响应时间**:检查用户操作的响应时间是否有所降低。
- **吞吐量**:测试系统单位时间内可以处理的请求数量。
- **资源消耗**:监控CPU和内存的使用情况,确保系统资源得到有效利用。
- **稳定性**:验证系统在高负载情况下的稳定性和可靠性。
性能对比的数据可以通过自动化测试工具获得,如JMeter或LoadRunner等。通过这些数据,我们可以评估并发优化带来的性能提升,并据此进行进一步的调优。
在总结阶段,我们需要根据性能测试的结果进行分析,确定优化是否成功,以及是否有必要进行后续的调整。成功的优化案例不仅提高了系统的性能,还增强了应用的可维护性和可扩展性,为未来的发展奠定了坚实的基础。
# 6. 未来展望:多线程编程的发展趋势
随着技术的不断进步和多核处理器的普及,多线程编程正在变得越来越重要。多线程编程的发展趋势不仅影响着软件开发的效率和质量,同时也挑战着开发者的技能和知识。本章节将探讨多线程编程的未来走向,包括新兴技术的影响、代码的可维护性与可读性以及多核时代的挑战与机遇。
## 6.1 新兴技术的影响
### 6.1.1 异步编程模型
异步编程模型是现代多线程编程的一个重要方向。与传统的同步编程模型相比,异步编程能够提高应用程序对I/O操作的响应性,减少线程的使用,从而降低资源消耗。例如,JavaScript的Promise、Python的asyncio库以及Java 8引入的CompletableFuture都是支持异步编程模型的工具。
异步编程模型的一个关键是回调地狱(Callback Hell)的处理,这在Node.js中尤为常见。为了解决这一问题,出现了异步函数(async/await),它允许开发者以同步的方式编写异步代码,提高代码的可读性和易管理性。
### 6.1.2 分布式计算框架
随着大数据的兴起,分布式计算框架如Hadoop和Spark已经成为处理大规模数据的首选。这些框架内部使用了多线程技术来提升计算性能和吞吐量,同时它们还引入了容错机制,以适应网络和硬件故障。
分布式计算框架的出现,使得开发者可以专注于业务逻辑的实现,而不需要从头开始构建复杂的多线程和分布式数据处理逻辑。这对于提升开发效率和降低错误率具有重要意义。
## 6.2 代码的可维护性与可读性
### 6.2.1 现代并发工具的使用
Java中的CompletableFuture、C++11的std::async和Python中的concurrent.futures都是现代并发工具的代表。这些工具提供了更高级的抽象,使得并发编程更加简洁和安全。开发者可以避免直接处理线程和锁,从而减少错误并提高生产效率。
使用这些现代并发工具的一个重要考量是它们的使用场景和性能特点。例如,CompletableFuture适用于复杂的异步流程控制,而std::async则更适合简单的并行执行任务。
### 6.2.2 设计模式在并发编程中的应用
设计模式是解决软件设计问题的通用解决方案。在并发编程中,合理地应用设计模式可以极大地提高代码的可维护性和可扩展性。例如:
- 单例模式可以确保全局只有一个实例,适用于需要跨线程共享资源的情况。
- 工厂模式可以帮助管理线程的创建和回收。
- 观察者模式可以用于线程间的消息传递和事件通知。
## 6.3 多核时代的挑战与机遇
### 6.3.1 多核处理器对并发编程的影响
多核处理器的发展为并发编程带来了前所未有的机遇,也带来了新的挑战。一方面,多核处理器提供了更多的处理能力,能够显著提高并发程序的性能。另一方面,开发者必须了解并掌握并行编程的知识,才能充分利用多核处理器的优势。
在多核环境下,开发者需要考虑数据的本地性和减少线程间通信的开销。此外,需要避免竞争条件、死锁和饥饿等并发问题。现代编程语言和工具通常提供了一些抽象和工具来帮助解决这些问题。
### 6.3.2 多线程编程的教育与培训
随着多线程编程变得越来越普遍,对开发人员进行多线程编程的教育与培训变得尤为重要。教育机构和企业需要更新课程和培训计划,以包含并行编程的概念、并发工具和最佳实践。
企业也应该鼓励开发者不断学习和实践新兴技术,如异步编程模型和现代并发工具。通过研讨会、在线课程和实践项目,开发者可以提高自己的技能,并适应多线程编程未来的发展趋势。
多线程编程的未来充满了挑战和机遇。新兴技术、设计模式的应用以及对多核处理器的充分利用,都要求开发者持续学习和适应变化。通过不断的学习和实践,开发者可以利用多线程编程的强大能力,创造出性能更优、响应性更高的软件产品。
0
0