阅读20:线程安全

6.031中的软件

防虫 容易明白 准备改变
今天改正,在未知的未来改正。 与未来的程序员(包括未来的您)进行清晰的沟通。 旨在适应变化而无需重写。

目标

回顾竞争条件:多个线程共享相同的可变变量,而不协调它们在做什么。这是不安全的,因为程序的正确性可能取决于其低级操作时序的偶然性。

基本上有四种方法可以确保共享内存并发中的变量访问安全:

  • 坐月子。 不要在线程之间共享变量。这个想法称为限制,我们今天将探讨它。
  • 不变性。使共享数据不变。我们已经讨论了很多关于不变性的内容,但是在本文中我们将讨论并发编程的其他一些限制。
  • 线程安全数据类型。将共享数据封装在为您进行协调的现有线程安全数据类型中。今天我们将讨论这一点。
  • 同步。使用同步可防止线程同时访问变量。同步是构建自己的线程安全数据类型所需要的。

我们将讨论本阅读中的前三种方式,以及如何使用这三种思路来论证您的代码是线程安全的。在下一篇文章中,我们将讨论第四种方法,即同步。

该阅读材料的灵感来自一本出色的书:Brian Goetz等人,《 Java Concurrency in Practice》,Addison-Wesley,2006年。

线程安全是什么意思

如果数据类型或静态方法在多个线程中使用时表现正确,则无论线程如何执行,并且不需要调用代码进行额外的协调,如果该数据类型或静态方法正常运行,则该方法是线程安全的

  • “行为正确”是指满足其规格并保留其rep不变性;
  • “不管线程如何执行”表示线程可能在多个处理器上或在同一处理器上有时间片;
  • “没有额外的协调”意味着数据类型不能在其调用者上设置与计时相关的前提条件,例如“get()在进行set()过程中不能调用”。

还记得Iterator吗 它不是线程安全的。 Iterator的规范说,您不能在遍历集合的同时修改集合。这是与调用者相关的与时间相关的前提条件,Iterator如果违反该条件,则不能保证其行为正确。

策略1:禁闭

我们实现线程安全的第一种方法是限制。线程限制是一个简单的想法:通过将可变数据限制在单个线程中,可以避免在可变数据上出现竞争。不要让任何其他线程直接读取或写入数据。

由于共享的可变数据是争用条件的根本原因,因此限制状态通过不共享可变数据来解决。

局部变量始终受线程限制。局部变量存储在堆栈中,每个线程都有自己的堆栈。一个方法可能同时运行多个调用(如果该方法是递归的,则运行在不同的线程中,甚至在单个线程堆栈的不同级别上),但是这些调用中的每一个都有其自己的变量私有副本,因此变量本身是受限的。

但是要小心–变量是线程限制的,但是如果它是对象引用,则还需要检查它指向的对象。如果对象是可变的,那么我们要检查对象是否也受到限制–不能从任何其他线程访问到该对象的引用。

坐月子是什么使访问niresult安全在这样的代码:

public class Factorial {/*** Computes n! and prints it on standard output.* @param n must be >= 0*/private static void computeFact(final int n) {BigInteger result = new BigInteger("1");for (int i = 1; i <= n; ++i) {System.out.println("working on fact " + n);result = result.multiply(new BigInteger(String.valueOf(i)));}System.out.println("fact(" + n + ") = " + result);}public static void main(String[] args) {new Thread(new Runnable() { // create a thread using anpublic void run() {     // anonymous RunnablecomputeFact(99);}}).start();computeFact(100);}
}

此代码启动线程computeFact(99)与匿名的 Runnable,在上次阅读讨论了常见的成语。

让我们看一下该代码的快照图。悬停或点按每个步骤以更新该图:

  1. 启动程序时,我们从运行一个线程开始main

  2. main使用匿名Runnable习语创建第二个线程,然后启动该线程。

  3. 至此,我们有两个并发的执行线程。他们的交织是未知的!但是发生下一件事的一种可能性是线程1进入computeFact

  4. 然后,可能发生的下一件事是线程2也进入computeFact

    在这一点上,我们可以看到如何约束有助于线程安全:每次执行computeFact都有自己的niresult变量。他们指向的对象都不是可变的。如果它们是可变的,则需要检查对象是否没有其他线程的别名。

  5. computeFact计算独立进行,更新它们各自的变量。

避免全局变量

与局部变量不同,静态变量不会自动进行线程限制。

如果程序中有静态变量,则必须提出一个论点,即只有一个线程会使用它们,并且必须清楚地记录这一事实。更好的是,您应该完全消除静态变量。

这是一个例子:

// This class has a race condition in it.
public class PinballSimulator {private static PinballSimulator simulator = null;// invariant: there should never be more than one PinballSimulator//            object createdprivate PinballSimulator() {System.out.println("created a PinballSimulator object");}// factory method that returns the sole PinballSimulator object,// creating it if it doesn't existpublic static PinballSimulator getInstance() {if (simulator == null) {simulator = new PinballSimulator();}return simulator;}
}

此类在getInstance()方法中存在竞争–两个线程可以同时调用它并最终创建PinballSimulator对象的两个副本,而这是我们不想要的。

要使用线程限制方法解决此问题,您可以指定仅允许调用某个线程(也许是“弹球模拟线程”)PinballSimulator.getInstance()。这里的风险是Java不能帮助您保证这一点。

通常,静态变量具有很高的并发风险。他们可能躲在似乎没有副作用或突变的无害功能后面。考虑以下示例:

// is this method threadsafe?
/*** @param x integer to test for primeness; requires x > 1* @return true if x is prime with high probability*/
public static boolean isPrime(int x) {if (cache.containsKey(x)) return cache.get(x);boolean answer = BigInteger.valueOf(x).isProbablePrime(100);cache.put(x, answer);return answer;
}private static Map<Integer,Boolean> cache = new HashMap<>();

此功能存储先前调用的答案,以防再次请求。这项技术称为“记忆化”,它是对诸如精确素数测试之类的缓慢功能的明智优化。但是现在isPrime从多个线程调用该方法并不安全,其客户端甚至可能没有意识到。原因是所有对的调用都共享HashMap静态变量引用的内容,并且不是线程安全的。如果有多个线程通过调用同时更改地图,则地图可能会以与上次读取中银行帐户损坏相同的方式损坏。如果您很幸运,损坏可能会导致哈希图中的异常,例如a或cacheisPrime()HashMapcache.put()Null­Pointer­ExceptionIndex­OutOfBounds­Exception。但是,正如我们在银行帐户示例中看到的那样,它也可能会悄悄地给出错误的答案。

阅读练习

阶乘
弹球模拟器
禁闭

策略2:不变性

实现线程安全的第二种方法是使用不可变的引用和数据类型。不变性解决了竞争条件下的共享可变数据原因,并通过使共享数据不可变简单地解决了该问题。

Final变量是不可变的引用,因此声明为final的变量可以安全地从多个线程访问。您只能读取变量,而不能写入变量。请注意,因为这种安全性仅适用于变量本身,并且我们仍然不得不争论变量指向的对象是不可变的。

不可变对象通常也是线程安全的。我们之所以说“通常”,是因为我们对不变性的当前定义对于并发编程而言过于宽松。我们已经说过,如果类型的对象在整个生命周期中始终代表相同的抽象值,则该类型是不可变的。但这实际上允许该类型自由更改其rep,只要客户看不到那些突变即可。我们已经看到了这个概念的几个例子,称为善意突变。缓存,延迟计算和数据结构重新平衡是典型的有益突变。

但是,对于并发来说,这种隐藏的突变并不安全。使用善意突变的不可变数据类型必须使用锁(可变数据类型所需的相同技术)使自己成为线程安全的,我们将在以后的阅读中讨论。

对不变性的更严格定义

因此,为了确信不可变数据类型是没有锁的线程安全的,我们需要对不可变性进行更严格的定义:

  • 没有变异器方法
  • 所有字段均为私有且为最终字段
  • 没有代表性的曝光
  • 代表中没有可变对象的任何突变-甚至没有善意的突变

如果遵循这些规则,那么您可以确信您的不可变类型也将是线程安全的。

在Java教程中,阅读:

  • 定义不可变对象的策略(1页)

阅读练习

不变性

策略3:使用Threadsafe数据类型

我们实现线程安全的第三个主要策略是将共享的可变数据存储在现有的线程安全数据类型中。

当Java库中的数据类型是线程安全的时,其文档将明确声明该事实。例如,这是StringBuffer所说的:

[StringBuffer是]线程安全的可变字符序列。字符串缓冲区类似于字符串,但是可以修改。它在任何时间点都包含一些特定的字符序列,但是可以通过某些方法调用来更改序列的长度和内容。

字符串缓冲区可安全用于多个线程。这些方法在必要时进行同步,以使任何特定实例上的所有操作都表现为好像以某种串行顺序发生,该顺序与所涉及的每个单独线程进行的方法调用的顺序一致。

这与StringBuilder相反:

[StringBuilder是]可变的字符序列。此类提供了与StringBuffer兼容的API,但不保证同步。此类设计为在单个线程正在使用字符串缓冲区的地方(通常是这种情况)来代替StringBuffer。在可能的情况下,建议优先使用此类而不是StringBuffer,因为在大多数实现中它会更快。

在Java API中,常见的是找到两种可做相同功能的可变数据类型,一种是线程安全的,而另一种则不是。原因是该引语所表明的:与不安全类型相比,线程安全数据类型通常会导致性能下降。

非常遗憾的是StringBufferStringBuilder它们如此相似地命名,而名称中没有任何迹象表明线程安全是它们之间的关键区别。不幸的是,它们没有共享公共接口,因此在需要线程安全的时候,您不能简单地将一种实现换成另一种实现。Java集合接口在这方面做得更好,我们将在后面看到。

线程安全集合

Java中的集合接口- ,,List -有没有线程的基本实现。你已经习惯使用,即这些的实现,以及不能安全地从多个线程使用。SetMapArrayListHashMapHashSet

幸运的是,就像Collections API提供了使包装不可变的包装器方法一样,它提供了另一套包装器方法来使收藏夹具有线程安全性,同时仍然是可变的。

这些包装器有效地使集合的每种方法都相对于其他方法具有原子性。一个原子操作发生的事却一下子-它不与其他行动的交错其内部运作,并没有一个行动的效果对其它线程可见,直到整个动作完成,所以它永远不会看起来部分完成。

现在,我们看到了一种解决isPrime()方法,该方法可以解决我们在阅读本文之前的方法:

private static Map<Integer,Boolean> cache =Collections.synchronizedMap(new HashMap<>());

这里有几点。

不要绕过包装纸。确保丢弃对底层非线程安全集合的引用,并仅通过同步包装器对其进行访问。由于新代码HashMap只会传递给synchronizedMap()其他地方,而不会存储在其他任何地方,因此上述代码行会自动发生这种情况。(我们在不可修改的包装器上也看到了同样的警告:底层集合仍然是可变的,引用它的代码可以规避不变性。)

迭代器仍然不是线程安全的。尽管集合本身(方法调用get()put()add()等)现在是线程安全的,从集合创建迭代器仍然没有线程安全的。因此,您不能使用iterator()或for循环语法:

for (String s: lst) { ... } // not threadsafe, even if lst is a synchronized list wrapper

解决此迭代问题的方法是在需要迭代集合时获取它的锁,我们将在以后的阅读中讨论它。

最后,原子操作还不足以防止竞争使用同步集合的方式仍然会存在竞争条件。考虑以下代码,该代码检查列表是否至少包含一个元素,然后获取该元素:

if ( ! lst.isEmpty()) { String s = lst.get(0); ... }

即使您lst进入一个同步列表,该代码仍然可能具有竞争条件,因为另一个线程可能会删除该isEmpty()调用和该get()调用之间的元素。

甚至该isPrime()方法仍然有潜在的竞争:

if (cache.containsKey(x)) return cache.get(x);
boolean answer = BigInteger.valueOf(x).isProbablePrime(100);
cache.put(x, answer);

同步映射确保了containsKey()get()put()现在原子,所以使用它们从多个线程不会损坏地图的Rep不变。但是,这三个操作现在可以以任意方式相互交错,这可能会破坏isPrime高速缓存所需的不变性:如果高速缓存将整数x映射到值f,则当且仅当f为true时,x才是质数。如果缓存使该不变式失败,那么我们可能会返回错误的结果。

因此,我们必须认为,之间的比赛containsKey()get()put()不威胁这个不变。

  1. containsKey()和之间的竞争get()是无害的,因为我们从不从缓存中删除项目-一旦它包含x的结果,它将继续这样做。
  2. containsKey()和之间有一场比赛put()。结果,可能最终两个线程将同时测试同一x的素数,并且两个都将竞相调用put()该答案。但是他们两个都应该put()用相同的答案跟注,因此哪个人赢得比赛无关紧要-结果将是相同的。

即使在使用线程安全数据类型时,也需要对安全性进行这类仔细的辩论,这是导致并发困难的主要原因。

在Java教程中,阅读:

  • 包装纸集(第1页)
  • 并发收藏(1页)

阅读练习

线程安全数据类型

如何提出安全论点

我们已经看到并发很难测试和调试。因此,如果您想让自己和其他人相信您的并发程序是正确的,最好的方法是提出一个明确的论据,说它没有种族,并将其写下来。

安全性参数需要对模块或程序中存在的所有线程及其使用的数据进行分类,并说明您要使用四种技术中的哪一种来防止每个数据对象或变量出现竞争:限制,不变性,线程安全数据类型或同步。当使用最后两个时,您还需要争辩说,对数据的所有访问都是适当的原子操作-也就是说,您所依赖的不变式不会受到交织的威胁。我们在isPrime上面给出了其中一个论点。

数据类型的线程安全参数

让我们看一些关于如何为数据类型创建线程安全参数的示例。记住我们的四种线程安全方法:限制,不变性,线程安全数据类型和同步。由于我们在本阅读中没有讨论同步,因此我们仅关注前三种方法。

当我们仅针对数据类型进行参数传递时,通常不选择限制,因为您必须知道系统中存在哪些线程以及已授予它们访问哪些对象。如果数据类型创建了自己的线程集,那么您可以谈论关于这些线程的限制。否则,线程从外部进入,进行客户端调用,并且数据类型可能无法保证哪个线程引用了什么。因此,在那种情况下,限制不是有用的论据。通常,我们在更高级别上使用限制,讨论整个系统并争论为什么某些模块或数据类型不需要线程安全,因为它们不会按设计在线程之间共享。

不变性通常是一个有用的论据:

/** MyString is an immutable data type representing a string of characters. */
public class MyString {private final char[] a;// Thread safety argument://    This class is threadsafe because it's immutable://    - a is final//    - a points to a mutable char array, but that array is encapsulated//      in this object, not shared with any other object or exposed to a//      client

这是MyString的另一个代表,需要在参数上多加注意:

/** MyString is an immutable data type representing a string of characters. */
public class MyString {private final char[] a;private final int start;private final int len;// Rep invariant://    0 <= start <= a.length//    0 <= len <= a.length-start// Abstraction function://    represents the string of characters a[start],...,a[start+length-1]// Thread safety argument://    This class is threadsafe because it's immutable://    - a, start, and len are final//    - a points to a mutable char array, which may be shared with other//      MyString objects, but they never mutate it//    - the array is never exposed to a client

请注意,由于此MyStringrep是为在多个MyString对象之间共享数组而设计的,因此我们必须确保共享不会威胁其线程安全。只要它不威胁MyString的不变性,我们就可以确信它不会威胁线程的安全性。

我们还必须避免代表接触。Rep公开对于任何数据类型都是有害的,因为它威胁到数据类型的rep不变式。线程安全也很致命。

错误的安全论点

这是一些关于线程安全的错误参数:

/** MyStringBuffer is a threadsafe mutable string of characters. */
public class MyStringBuffer {private String text;// Rep invariant://   none// Abstraction function://   represents the sequence text[0],...,text[text.length()-1]// Thread safety argument://   text is an immutable (and hence threadsafe) String,//   so this object is also threadsafe

为什么这种论点行不通?字符串确实是不可变的并且是线程安全的。但是指向该字符串(特别是text变量)的代表不是不可变的。 text是不是最终的变数,而事实上它不可能是最终在这个数据类型,因为我们需要的数据类型来支持插入和删除操作。因此,text变量本身的读取和写入不是线程安全的。这个说法是错误的。

这是另一个破裂的论点:

public class Graph {private final Set<Node> nodes =Collections.synchronizedSet(new HashSet<>());private final Map<Node,Set<Node>> edges =Collections.synchronizedMap(new HashMap<>());// Rep invariant://    for all x, y such that y is a member of edges.get(x),//        x, y are both members of nodes// Abstraction function://    represents a directed graph whose nodes are the set of nodes//        and whose edges are the set (x,y) such that//                         y is a member of edges.get(x)// Thread safety argument://    - nodes and edges are final, so those variables are immutable//      and threadsafe//    - nodes and edges point to threadsafe set and map data types

这是一种图形数据类型,将其节点存储在集合中,并将其边缘存储在地图中。(快速测验:是Graph可变的还是不可变的数据类型?final关键字与它的可变性有什么关系?)Graph依赖于其他线程安全的数据类型来帮助其实现rep –特别是我们上面所讨论的线程安全SetMap包装器。这阻止了某些竞争条件,但不是全部,因为图形的rep不变量包括节点集和边缘贴图之间的关系。出现在边缘贴图中的所有节点也必须出现在节点集中。所以可能有这样的代码:

public void addEdge(Node from, Node to) {if ( ! edges.containsKey(from)) {edges.put(from, Collections.synchronizedSet(new HashSet<>()));}edges.get(from).add(to);nodes.add(from);nodes.add(to);
}

该代码中包含竞争条件。有一个关键时刻,就是在edges映射发生变异后,但在nodes集合发生变异之前,就违反了rep不变式。图上的另一个操作可能会在此时交织,发现rep不变式已损坏,并返回错误的结果。即使线程安全的set和map数据类型保证其自身add()put()方法是原子的且互不干扰,它们也不能将该保证扩展到两个数据结构之间的交互。因此,rep不变式在Graph种族条件下并不安全。当rep不变式取决于rep中对象之间的关系时,仅使用不可变和线程安全可变数据类型是不够的。

我们将不得不通过同步来解决此问题,我们将在以后的阅读中看到如何解决。

阅读练习

安全论点

可序列化

再次查看上述练习的代码。我们可能还会担心clearinsert可能交错,以使客户看到clear违反其后置条件。

一种
称呼 sb.clear()  
  称呼 sb.insert(0, "a")
—在cleartext = ""  
  —在inserttext = "" + "a" + "z"
clear退货  
  insert退货
assert sb.toString()
            .equals("")
 

假设两个线程共享MyStringBuffer sb表示"z"。他们运行clearinsert在如右图所示兼任。

线程A的声明将失败,但不是因为clear违反了它的后置条件。的确,当所有代码clear都已运行完毕时,后置条件就满足了。

真正的问题是线程A尚未预期到clear()和之间的可能的交织assert。对于任何可同时调用原子变异体的线程安全可变类型,某些变异必须作为最后一个应用来“获胜”。线程A观察到的结果与下面的执行相同,在这里执行器完全不交织:

一种
称呼 sb.clear()  
—在cleartext = ""  
clear退货  
  称呼 sb.insert(0, "a")
  —在inserttext = "" + "a" + ""
  insert退货
assert sb.toString()
            .equals("")
 

我们对线程安全数据类型的要求是,当客户端同时调用其原子操作时,结果与这些调用的某些顺序保持一致。在这种情况下,清除并插入意味着-clear跟随-insert或-insert跟随- clear。此属性称为可序列化:对于同时执行的任何一组操作,结果(客户端可观察的值和状态)必须是由这些操作的某些顺序给出的结果。

阅读练习

可序列化

概括

本文讨论了通过共享可变数据在竞争条件下实现安全的三种主要方法:

  • 限制:不共享数据。
  • 不变性:共享,但保持数据不变。
  • 线程安全数据类型:将共享的可变数据存储在单个线程安全数据类型中。

这些想法与我们的优质软件的三个关键属性有关,如下所示:

  • 安全的错误。 我们正在尝试消除主要的并发性错误,竞争条件,并通过设计消除它们,而不仅仅是偶然的计时。

  • 容易明白。 应用这些通用,简单的设计模式比关于哪些线程可以插入和哪些线程不能插入的复杂争论要容易得多。

  • 准备好进行更改。 我们正在线程安全参数中显式地写下这些理由,以便维护程序员知道代码对其线程安全的依赖。

软件构造-线程安全性相关推荐

  1. 并发编程-03线程安全性之原子性(Atomic包)及原理分析

    文章目录 线程安全性文章索引 脑图 线程安全性的定义 线程安全性的体现 原子性 使用AtomicInteger改造线程不安全的变量 incrementAndGet源码分析-UnSafe类 compar ...

  2. 并发基础知识 — 线程安全性

    前段时间看完了<并发编程的艺术>,总感觉自己对于并发缺少一些整体的认识.今天借助<Java并发编程实践>,从一些基本概念开始,重新整理一下自己学过并发编程.从并发基础开始,深入 ...

  3. Java并发编程(五):Java线程安全性中的对象发布和逸出

    发布(Publish)和逸出(Escape)这两个概念倒是第一次听说,不过它在实际当中却十分常见,这和Java并发编程的线程安全性就很大的关系. 什么是发布?简单来说就是提供一个对象的引用给作用域之外 ...

  4. Spring 获取 request 的几种方法及其线程安全性分析

    转载自  Spring 获取 request 的几种方法及其线程安全性分析 本文将介绍在Spring MVC开发的Web系统中,获取request对象的几种方法,并讨论其线程安全性. 一.概述 在使用 ...

  5. Java集合(实现类线程安全性)

    转载自  Java集合(实现类线程安全性) 1.集合和Map 下图是Java集合的Collection集合体系的继承树: 下图是Java的Map体系的继承树: 对于Set.List.Queue和Map ...

  6. Spring学习总结(28)——Spring获取request对象的几种方法及线程安全性总结

    分享一个大神的人工智能教程.零基础!通俗易懂!风趣幽默!还带黄段子!希望你也加入到人工智能的队伍中来!点击浏览教程 前言 在使用Spring MVC开发Web系统时,经常需要在处理请求时使用reque ...

  7. Spring 中获取 request 的几种方法,及其线程安全性分析

    概述 在使用Spring MVC开发Web系统时,经常需要在处理请求时使用request对象,比如获取客户端ip地址.请求的url.header中的属性(如cookie.授权信息).body中的数据等 ...

  8. 【Java并发.2】线程安全性

    要编写线程安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享(Shared)和可变的(Mutable)状态的访问. "共享"意味着变量可以由多个线程同时访问,而" ...

  9. Java多线程编程(3)--线程安全性

    一.线程安全性   一般而言,如果一个类在单线程环境下能够运作正常,并且在多线程环境下,在其使用方不必为其做任何改变的情况下也能运作正常,那么我们就称其是线程安全的.反之,如果一个类在单线程环境下运作 ...

  10. 可重入性和线程安全性

    可重入性和线程安全性均与函数处理资源的方式有关. 但是,它们是不同的: 可重入函数既不会在连续调用中存储静态数据,也不会返回指向静态数据的指针. 对于这种类型的函数,调用方将提供函数所需的所有数据,如 ...

最新文章

  1. 提取指定的PDF表格保存到Excel
  2. 操作系统修炼秘籍(1):秘籍简介
  3. centos7安装telnet后/etc/xinetd.d/telnet下没有telnet
  4. python实现单链表快速排序升序linkedqueue_LeetCode 总结 - 搞定 Linked List 面试题
  5. qmail 反垃圾邮件
  6. 正则提取 html 里input 标记的value 值
  7. 【Python-二分法-查找重复值】
  8. 【ARM嵌入式】——多寄存器寻址
  9. U盘数据恢复软件推荐
  10. 最优化方法 19:近似梯度下降
  11. c语言黑熊,BLACK BRUIN黑熊扭矩马达C5510000AA
  12. 关于Chartboost的视频广告
  13. MacOs 查看本地IP和Mac地址
  14. c语言中min函数的作用,min函数到底在哪个头文件里?
  15. 基于单片机多功能万年历系统毕业设计(完整资料)
  16. echarts柱状图实现正负值不同颜色在上方显示数值
  17. Excel与PowerBI 之PowerQuery 编辑界面异同-PowerQuery 系列文章之三
  18. Elliptic Labs进入奥斯陆证券交易所主板上市名单
  19. windows10 自带的office突然无缘故被卸载了
  20. winrar40天试用完了

热门文章

  1. 2022年日历表电子版打印版
  2. Java实现zip文件压缩与解压缩--附完整代码
  3. 如何彻底删除adobe?adobe官方清理工具怎么用?
  4. 视频教程-【国内首套H3C V7交换机实战课程-2】以太网端口和链路聚合配置与管理-H3C认证
  5. 毕设教程系列 - FCM模糊聚类算法
  6. Ubuntu configuration-1 安装常用软件
  7. Android手机多种截图方式
  8. mini programe
  9. css居中对齐的几种方式
  10. TB6612FNG 驱动学习笔记