本文是我们学院课程中名为Java Concurrency Essentials的一部分 。

在本课程中,您将深入探讨并发的魔力。 将向您介绍并发和并发代码的基础知识,并学习诸如原子性,同步和线程安全之类的概念。 在这里查看 !

目录

1.活泼
1.1。 僵局 1.2。 饥饿
2.使用wait()和notify()进行对象监控
2.1。 带wait()和notify()的嵌套同步块 2.2。 同步块中的条件
3.设计多线程
3.1。 不变的对象 3.2。 API设计 3.3。 线程本地存储

1.活泼

在开发使用并发实现其目标的应用程序时,您可能会遇到不同线程可能相互阻塞的情况。 由于整个应用程序的运行速度比预期的慢,因此我们可以说该应用程序无法按预期的时间完成。 在本节中,我们将仔细研究可能危害多线程应用程序正常运行的问题。

僵局

术语“死锁”对于软件开发人员来说是众所周知的,即使是大多数普通的计算机用户也经常使用“死锁”这个术语,尽管它并非总是以正确的含义使用。 严格地说,这意味着两个(或多个)线程分别在另一个线程上等待以释放它已锁定的资源,而线程本身已锁定另一个线程在等待的资源:

Thread 1: locks resource A, waits for resource BThread 2: locks resource B, waits for resource A

为了更好地理解该问题,让我们看一下以下源代码:

public class Deadlock implements Runnable {private static final Object resource1 = new Object();private static final Object resource2 = new Object();private final Random random = new Random(System.currentTimeMillis());public static void main(String[] args) {Thread myThread1 = new Thread(new Deadlock(), "thread-1");Thread myThread2 = new Thread(new Deadlock(), "thread-2");myThread1.start();myThread2.start();}public void run() {for (int i = 0; i < 10000; i++) {boolean b = random.nextBoolean();if (b) {System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1.");synchronized (resource1) {System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1.");System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2.");synchronized (resource2) {System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2.");}}} else {System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2.");synchronized (resource2) {System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2.");System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1.");synchronized (resource1) {System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1.");}}}}}
}

从上面的代码可以看出,启动了两个线程并尝试锁定两个静态资源。 但是对于死锁,两个线程需要不同的顺序,因此我们利用Random实例选择线程首先要锁定的资源。 如果布尔变量b为true,则首先锁定resource1,然后线程尝试获取对资源2的锁定。如果b为false,则线程首先锁定resource2,然后尝试锁定resource1。 在我们到达第一个死锁之前,该程序不必运行很长时间,即,如果我们不终止它,该程序将永远挂起:

[thread-1] Trying to lock resource 1.[thread-1] Locked resource 1.[thread-1] Trying to lock resource 2.[thread-1] Locked resource 2.[thread-2] Trying to lock resource 1.[thread-2] Locked resource 1.[thread-1] Trying to lock resource 2.[thread-1] Locked resource 2.[thread-2] Trying to lock resource 2.[thread-1] Trying to lock resource 1.

在此执行中,线程1持有资源2的锁并等待对resource1的锁,而线程2持有资源1的锁并等待resource2。

如果将上面示例代码中的布尔变量b设置为true,则不会遇到任何死锁,因为线程1和线程2请求锁的顺序始终相同。 因此,两个线程中的一个首先获取锁,然后请求第二个锁,由于其他线程等待第一个锁,第二个锁仍然可用。

通常,可以确定以下死锁要求:

  • 互斥:有一种资源在任何时间点只能由一个线程访问。
  • 资源持有:锁定一个资源后,线程尝试获取对某个其他排他资源的另一个锁定。
  • 无抢占:没有机制,如果一个线程在特定时间段内持有锁,则该机制可以释放资源。
  • 循环等待:在运行时发生一个星座,其中两个(或更多)线程分别在另一个线程上等待以释放已锁定的资源。

尽管要求列表看起来很长,但是更高级的多线程应用程序存在死锁问题并不罕见。 但是,如果您能够放松上面列出的要求之一,则可以尝试避免死锁:

  • 互斥:这是一项通常不能放宽的要求,因为必须专门使用资源。 但这并非总是如此。 使用DBMS系统时,可以使用一种称为Optimistic Locking的技术,而不是在必须更新的某些表行上使用悲观锁,这是一种可能的解决方案。
  • 在等待另一个排他资源时避免资源持有的可能解决方案是在算法开始时锁定所有必要的资源,并在不可能获得所有锁定的情况下释放所有资源。 当然,这并非总是可能的,也许锁定的资源并不为人所知,或者就像浪费资源一样。
  • 如果无法立即获得锁定,则避免超时的可能解决方案是引入超时。 例如,SDK类ReentrantLock提供了指定锁定超时的可能性。
  • 从上面的示例代码可以看出,如果不同线程之间的锁定请求顺序没有不同,则不会出现死锁。 如果您能够将所有锁定代码放入所有线程都必须通过的一种方法中,则可以轻松地控制它。

在更高级的应用程序中,您甚至可以考虑实现死锁检测系统。 在这里,您将必须实现某种类型的线程监视,其中每个线程都报告已成功获取锁以及其尝试获取锁的尝试。 如果将线程和锁建模为有向图,则可以检测到两个不同的线程何时拥有资源,同时请求另一个阻塞的资源。 如果然后您可以强制阻塞线程释放获得的资源,则可以自动解决死锁情况。

饥饿

调度程序决定下一步应该在状态RUNNABLE中执行的线程 。 该决定基于线程的优先级; 因此,具有较低优先级的线程比具有较高优先级的线程获得的CPU时间更少。 听起来很合理的功能在滥用时也会引起问题。 如果大多数时间具有高优先级的线程被执行,则低优先级的线程似乎“饿死了”,因为它们没有足够的时间正确执行其工作。 因此,建议仅在有充分理由的情况下设置线程的优先级。

线程匮乏的一个复杂示例是例如finalize()方法。 Java语言的此功能可用于在对象被垃圾回收之前执行代码。 但是,当您查看终结器线程的优先级时,您可能会发现它的运行优先级最高。 因此,如果与其他代码相比,对象的finalize()方法花费太多时间,则可能导致线程不足。

执行时间的另一个问题是问题,即未定义线程以哪个顺序传递同步块。 当许多并行线程必须传递封装在同步块中的某些代码时,某些线程可能比其他线程要等待更长的时间才能进入该块。 从理论上讲,它们可能永远不会进入障碍。

后一种问题的解决方案是所谓的“公平”锁定。 在选择下一个要传递的线程时,公平锁会考虑线程的等待时间。 Java SDK提供了一个公平锁的示例实现:java.util.concurrent.locks.ReentrantLock。 如果使用布尔标志设置为true的构造函数,则ReentrantLock授予对等待时间最长的线程的访问权限。 这保证了没有饥饿,但是同时引入了以下问题:没有考虑线程优先级,因此可能会更频繁地执行经常在此屏障处等待的优先级较低的线程。 最后但并非最不重要的一点是,ReentrantLock类当然只能考虑正在等待锁的线程,即,执行频率足以达到锁的线程。 如果线程优先级太低,则可能不会经常发生这种情况,因此,具有更高优先级的线程仍会更频繁地通过锁。

2.使用wait()和notify()进行对象监控

多线程计算中的一项常见任务是让一些工作线程正在等待其生产者为其创建工作。 但是,据我们了解,就CPU时间而言,在循环中忙于等待并检查某些值并不是一个好的选择。 在此用例中,Thread.sleep()方法也没有太大价值,因为我们希望在提交后立即开始工作。

因此,Java编程语言具有另一种可在这种情况下使用的构造:wait()和notify()。 每个对象都从java.lang.Object类继承的wait()方法可用于暂停当前线程执行,并等待直到另一个线程使用notify()方法将我们唤醒。 为了正常工作,调用wait()方法的线程必须持有它已获得的锁,然后才能使用synced关键字。 当调用wait()时,锁被释放,线程等待直到拥有该锁的另一个线程在同一对象实例上调用notify()为止。

在多线程应用程序中,当然可能有多个线程在等待某个对象的通知。 因此,有两种不同的唤醒线程的方法:notify()和notifyAll()。 第一个方法仅唤醒一个等待线程,而notifyAll()方法将它们全部唤醒。 但是请注意,类似于synced关键字,没有规则指定调用notify()时接下来唤醒哪个线程。 在简单的生产者和消费者示例中,这无关紧要,因为我们对哪个线程完全唤醒的事实不感兴趣。

下面的代码演示了如何使用wait()和notify()机制来让使用者线程等待从某些生产者线程推送到队列中的新工作:

package a2;import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;public class ConsumerProducer {private static final Queue queue = new ConcurrentLinkedQueue();private static final long startMillis = System.currentTimeMillis();public static class Consumer implements Runnable {public void run() {while (System.currentTimeMillis() < (startMillis + 10000)) {synchronized (queue) {try {queue.wait();} catch (InterruptedException e) {e.printStackTrace();}}if (!queue.isEmpty()) {Integer integer = queue.poll();System.out.println("[" + Thread.currentThread().getName() + "]: " + integer);}}}}public static class Producer implements Runnable {public void run() {int i = 0;while (System.currentTimeMillis() < (startMillis + 10000)) {queue.add(i++);synchronized (queue) {queue.notify();}try {Thread.sleep(100);} catch (InterruptedException e) {e.printStackTrace();}}synchronized (queue) {queue.notifyAll();}}}public static void main(String[] args) throws InterruptedException {Thread[] consumerThreads = new Thread[5];for (int i = 0; i < consumerThreads.length; i++) {consumerThreads[i] = new Thread(new Consumer(), "consumer-" + i);consumerThreads[i].start();}Thread producerThread = new Thread(new Producer(), "producer");producerThread.start();for (int i = 0; i < consumerThreads.length; i++) {consumerThreads[i].join();}producerThread.join();}
}

main()方法启动五个使用者和一个生产者线程,然后等待它们完成。 然后,生产者线程将新值插入队列,然后通知所有等待线程发生了某些事情。 使用者线程获取队列锁,然后进入睡眠状态,以便稍后再次填充队列时被唤醒。 生产者线程完成工作后,会通知所有消费者线程唤醒。 如果我们不做最后一步,那么消费者线程将永远等待下一个通知,因为我们没有为等待指定任何超时。 取而代之的是,我们至少可以在经过一定时间后使用wait(long timeout)方法来唤醒它。

带wait()和notify()的嵌套同步块

如上一节所述,在对象监视器上调用wait()仅释放该对象监视器上的锁。 由同一线程持有的其他锁不会被释放。 因为这很容易理解,所以在日常工作中,调用wait()的线程可能会进一步锁定。 而且,如果其他线程也在等待这些锁,则会发生死锁情况。 让我们看下面的示例代码:

public class SynchronizedAndWait {private static final Queue queue = new ConcurrentLinkedQueue();public synchronized Integer getNextInt() {Integer retVal = null;while (retVal == null) {synchronized (queue) {try {queue.wait();} catch (InterruptedException e) {e.printStackTrace();}retVal = queue.poll();}}return retVal;}public synchronized void putInt(Integer value) {synchronized (queue) {queue.add(value);queue.notify();}}public static void main(String[] args) throws InterruptedException {final SynchronizedAndWait queue = new SynchronizedAndWait();Thread thread1 = new Thread(new Runnable() {public void run() {for (int i = 0; i < 10; i++) {queue.putInt(i);}}});Thread thread2 = new Thread(new Runnable() {public void run() {for (int i = 0; i < 10; i++) {Integer nextInt = queue.getNextInt();System.out.println("Next int: " + nextInt);}}});thread1.start();thread2.start();thread1.join();thread2.join();}
}

正如我们之前所了解的 ,将同步添加到方法签名等同于创建一个synced(this){}块。 在上面的示例中,我们意外地向该方法添加了synced关键字,然后在对象监视器队列上进行了同步,以便在等待队列中的下一个值时将当前线程置于睡眠状态。 然后,当前线程释放队列上的锁保持,但不释放对此的锁保持。 putInt()方法通知睡眠线程已添加新值。 但是,偶然地,我们还向该方法添加了关键字sync。 现在,第二个线程进入睡眠状态时,它仍然保持锁定状态。 然后,第一个线程无法进入方法putInt(),因为此锁由第一个线程持有。 因此,我们陷入僵局,程序挂起。 如果执行上面的代码,则在程序开始后立即发生。

在日常生活中,情况可能不像上面那样清楚。 线程持有的锁可能取决于运行时参数和条件,导致问题的同步块可能与代码中我们放置wait()调用的位置不太接近。 这使得很难找到此类问题,并且可能是这些问题仅在一段时间后或在高负荷下才会出现。

同步块中的条件

在同步对象上执行某些操作之前,通常必须检查是否满足某些条件。 例如,当您有一个队列时,您要等待直到该队列被填满。 因此,您可以编写一种检查队列是否已满的方法。 如果不是,则在唤醒当前线程之前使其处于睡眠状态:

public Integer getNextInt() {Integer retVal = null;synchronized (queue) {try {while (queue.isEmpty()) {queue.wait();}} catch (InterruptedException e) {e.printStackTrace();}}synchronized (queue) {retVal = queue.poll();if (retVal == null) {System.err.println("retVal is null");throw new IllegalStateException();}}return retVal;
}

上面的代码在调用wait()之前在队列上进行同步,然后在while循环内等待,直到队列中至少有一个条目。 第二个同步块再次将队列用作对象监视器。 它轮询()队列中的内部值。 为了演示起见,当poll()返回null时,抛出IllegalStateException。 当队列中没有要轮询的值时,就是这种情况。

运行此示例时,您将看到IllegalStateException很快就会抛出。 尽管我们已经在队列监视器上正确地同步了,但是会引发异常。 原因是我们有两个单独的同步块。 假设我们有两个线程到达了第一个同步块。 第一个线程进入该块并由于队列为空而进入睡眠状态。 第二个线程也是如此。 现在,当两个线程都唤醒时(通过另一个在监视器上调用notifyAll()的线程),它们都在队列中看到一个值(生产者添加的值。然后,两个线程到达第二个屏障。在这里,第一个线程进入轮询队列中的值,当第二个线程进入时,队列已为空,因此它从poll()调用返回的值作为null并引发异常。

为避免出现上述情况,您将必须在同一同步块中执行所有取决于监视器状态的操作:

public Integer getNextInt() {Integer retVal = null;synchronized (queue) {try {while (queue.isEmpty()) {queue.wait();}} catch (InterruptedException e) {e.printStackTrace();}retVal = queue.poll();}return retVal;
}

在这里,我们在与isEmpty()方法相同的同步块中执行poll()方法。 通过同步块,我们可以确保在给定的时间点上只有一个线程正在此监视器上执行方法。 因此,没有其他线程可以从isEmpty()和poll()调用之间的队列中删除元素。

3.设计多线程

正如我们在最后几节中所看到的,实现多线程应用程序有时比乍一看要复杂。 因此,在启动项目时,请务必牢记清晰的设计。

不变的对象

在这种情况下,非常重要的一种设计规则是不变性。 如果在不同线程之间共享对象实例,则必须注意两个线程不会同时修改同一对象。 但是在无法更改的情况下,不可修改的对象很容易处理。 要修改数据时,始终必须构造一个新实例。 基本类java.lang.String是不可变类的示例。 每次您要更改字符串时,都会得到一个新实例:

String str = "abc";String substr = str.substring(1);

尽管创建对象的操作并非没有成本,但是这些成本经常被高估。 但是,如果具有不可变对象的简单设计胜过不使用不可变对象,则总要权衡一下,因为存在存在并发错误的风险,在项目中可能会发现并发错误。

在下面的内容中,您将找到一组要使类不可变的适用规则:

  • 所有字段均应为最终字段和私有字段。
  • 不应使用setter方法。
  • 应该将类本身声明为final,以防止子类违反不变性原则。
  • 如果字段不是原始类型,而是对另一个对象的引用:
    • 不应有将引用直接暴露给调用者的getter方法。

下列类的实例表示一条消息,其中包含主题,消息正文和一些键/值对:

public final class ImmutableMessage {private final String subject;private final String message;private final Map<String,String> header;public ImmutableMessage(Map<String,String> header, String subject, String message) {this.header = new HashMap<String,String>(header);this.subject = subject;this.message = message;}public String getSubject() {return subject;}public String getMessage() {return message;}public String getHeader(String key) {return this.header.get(key);}public Map<String,String> getHeaders() {return Collections.unmodifiableMap(this.header);}
}

该类是不可变的,因为它的所有字段都是final和private。 在构造实例后,没有任何方法可以修改实例的状态。 返回对主题和消息的引用是安全的,因为String本身是一个不变的类。 例如,获得消息引用的呼叫者无法直接对其进行修改。 对于标题映射,我们必须更加注意。 只要返回对Map的引用,调用者就可以更改其内容。 因此,我们必须返回通过调用Collections.unmodifiableMap()获得的不可修改Map。 这将返回Map上的视图,该视图允许调用者读取值(再次为字符串),但不允许修改。 尝试修改Map实例时,将引发UnsupportedOperationException。 在此示例中,返回特定键的值也是安全的,就像在getHeader(String key)中完成操作一样,因为返回的String再次是不可变的。 如果Map包含本身不可变的对象,则此操作将不是线程安全的。

API设计

在设计类的公共方法(即此类的API)时,您也可以尝试将其设计用于多线程使用。 当对象处于特定状态时,您可能有不应执行的方法。 克服这种情况的一个简单解决方案是拥有一个私有标志,该标志指示我们处于哪种状态,并且在不应调用特定方法时抛出IllegalStateException:

public class Job {private boolean running = false;private final String filename;public Job(String filename) {this.filename = filename;}public synchronized void start() {if(running) {throw new IllegalStateException("...");}...}public synchronized List getResults() {if(!running) {throw new IllegalStateException("...");}...}
}

上面的模式通常也称为“禁止模式”,因为该方法一旦在错误的状态下执行便会失败。 但是您可以使用静态工厂方法设计相同的功能,而无需在每个方法中检查对象的状态:

public class Job {private final String filename;private Job(String filename) {this.filename = filename;}public static Job createAndStart(String filename) {Job job = new Job(filename);job.start();return job;}private void start() {...}public synchronized List getResults() {...}
}

静态工厂方法使用私有构造函数创建Job的新实例,并已在实例上调用start()。 返回的Job引用已经处于可以使用的正确状态,因此getResults()方法仅需要同步,而不必检查对象的状态。

线程本地存储

到目前为止,我们已经看到线程共享相同的内存。 就性能而言,这是在线程之间共享数据的好方法。 如果我们将使用单独的进程来并行执行代码,那么我们将拥有更繁重的数据交换方法,例如远程过程调用或文件系统或网络级别的同步。 但是,如果同步不正确,则在不同线程之间共享内存也将难以处理。

Java中的java.lang.ThreadLocal类提供了仅由我们自己的线程而不是其他线程使用的专用内存:

private static final ThreadLocal myThreadLocalInteger = new ThreadLocal();

通用模板参数T给出了应存储在ThreadLocal内的数据类型。在上面的示例中,我们仅使用了Integer,但在这里我们也可以使用任何其他数据类型。 以下代码演示了ThreadLocal的用法:

public class ThreadLocalExample implements Runnable {private static final ThreadLocal threadLocal = new ThreadLocal();private final int value;public ThreadLocalExample(int value) {this.value = value;}@Overridepublic void run() {threadLocal.set(value);Integer integer = threadLocal.get();System.out.println("[" + Thread.currentThread().getName() + "]: " + integer);}public static void main(String[] args) throws InterruptedException {Thread threads[] = new Thread[5];for (int i = 0; i < threads.length; i++) {threads[i] = new Thread(new ThreadLocalExample(i), "thread-" + i);threads[i].start();}for (int i = 0; i < threads.length; i++) {threads[i].join();}}
}

您可能想知道,即使变量threadLocal被声明为静态的,每个线程也输出的正是通过构造函数获得的值。 ThreadLocal的内部实现确保每次调用set()时,给定值都存储在仅当前线程有权访问的内存区域中。 因此,当您事后调用get()时,尽管存在其他线程可能已经调用过set()的事实,但仍会检索之前设置的值。

Java EE世界中的应用程序服务器大量使用ThreadLocal功能,因为您有许多并行线程,但是每个线程都有自己的事务或安全上下文。 由于您不想在每次方法调用中传递这些对象,因此只需将其存储在线程自己的内存中,并在以后需要时访问它。

翻译自: https://www.javacodegeeks.com/2015/09/concurrency-fundamentals-deadlocks-and-object-monitors.html

并发基础知识:死锁和对象监视器相关推荐

  1. mysql 死锁监视器_并发基础知识:死锁和对象监视器

    mysql 死锁监视器 本文是我们名为Java Concurrency Essentials的学院课程的一部分. 在本课程中,您将深入探讨并发的魔力. 将向您介绍并发和并发代码的基础知识,并学习诸如原 ...

  2. Python学习之并发基础知识

    8 并发编程 8.1 基础知识 8.1.1 操作系统的定义 操作系统是存在于硬件与软件之间,管理.协调.调度软件与硬件的交互. 资源管理解决物理资源数量不足和合理分配资源这两个问题, 通俗来说,操作系 ...

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

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

  4. 并发系列1:并发基础知识

    本文是Java并发系列的开篇,主要讲一些并发的计算机基础知识.本系列所讲的知识框架也是基于<Java并发编程的艺术>一书,所讲的内容也多围绕于并发concurrent包下的类. 正文 并发 ...

  5. Java并发基础知识,我用思维导图整理好了

    文章目录 1.基本概念 2.线程创建和运行 3.常用方法 3.1.线程等待与通知 3.2.线程休眠 3.3.让出优先权 3.4.线程中断 4.线程状态 5.线程上下文切换 6.线程死锁 7.线程分类 ...

  6. 互联网开发(一) 并发基础知识

    一.并发的基本含义         在操作系统中,并发是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行. 在关 ...

  7. Java并发基础知识(五)

    线程池 为什么要用线程池? Java中的线程池是运用场景最多的并发框架,几乎所有需要异步或并发执行任务的程序都可以使用线程池.在开发过程中,合理地使用线程池能够带来3个好处. 第一:降低资源消耗.通过 ...

  8. java并发基础(二)--- Java监视器模型、并发容器、同步工具类(闭锁、信号量)

    原blog链接:http://www.cnblogs.com/peterxiao/p/6921903.html 总结一下第4.5章的东西. 一.java监视器模式 概念:把对象的所有可变状态都封装起来 ...

  9. java基础知识1---面向对象及final,finally,finalize区别

    1.面向对象(OOP)的特征 •抽象 :就是把现实世界中的某一方面提取出来,用程序代码表示,抽象出来的一般叫做类或接口. 抽象包括两个方面,一个数据抽象,另一个是过程抽象. 过程抽象 :表示功能的操作 ...

最新文章

  1. 查看linux主机是否安装宋体码,Linux 安装宋体字体的简单办法
  2. 利用CodeBERT,这个VS Code扩展可以自动生成Python文档字符串
  3. Bash,Vim,gdbgit常用命令
  4. 如何导入别人的android studio项目,解决gradle版本不兼容问题
  5. 【有奖征文】如何提高IDC机房服务器的安全性
  6. 在一台服务器绑定多个IP
  7. clickhouse 航空数据_ClickHouse空间分析运用
  8. 计算机硬件知识竞赛题库,电脑知识竞赛题库.pdf
  9. 创业期的软件开发管理(一)
  10. 带你学会区分Scheduled Thread Pool Executor 与Timer
  11. control层alert弹出框乱码_【ArcGIS for JS】动态图层的属性查询(11)
  12. vhg电路是什么意思_电路板打样是什么意思?
  13. Linux 如何创建进程函数与查看进程
  14. 终于来了!Pyston v2.0 发布,速度比 Python 快 20%!
  15. ddmmyy日期格式是多少_DDMMYY什么意思?
  16. java统计单机次数_java流类,快速统计出字符次数+++
  17. Quartz定时器实现
  18. 揭秘Google数据中心网络B4(李博杰)
  19. IntelliJ IDEA pycharm webstorm 激活
  20. Shop项目后台--4.所有订单的订单详情/admin/order/list.jsp

热门文章

  1. Hibernate中使用Criteria查询及注解——(Dept.hbm.xml)
  2. ReactiveLodeBalancerClientFilter响应式负载均衡代理
  3. centos5.9 mysql_CentOS 5.9系统服务器使用yum安装Apache+PHP+MySQL环境
  4. 2017尼毕鲁笔试算法题
  5. ibatis(2)ibatis是什么
  6. MySQL存储过程+游标+触发器
  7. JAVA实现一个图片上传预览功能
  8. Spring boot(十二):Spring boot 如何测试、打包、部署
  9. java fix_Java中的低延迟FIX引擎
  10. flyway数据迁移_使用Flyway在Java EE中进行数据库迁移