首页 技术 正文
技术 2022年11月15日
0 收藏 334 点赞 4,171 浏览 11416 个字

[Effective Java]第十章 并发声明:原创作品,转载时请注明文章来自SAP师太技术博客( 博/客/园www.cnblogs.com):www.cnblogs.com/jiangzhengjun,并以超链接形式标明文章原始出处,否则将追究法律责任!原文链接:http://www.cnblogs.com/jiangzhengjun/p/4255698.html

第十章      并发

66、      同步访问共享的可变数据

许多程序员把同步的概念仅仅理解为一个种互斥的方式,即,当一个对象被一个线程修改的时候,可以阻止另一个线程观察到对象的内部不一致的状态。正确地使用同步可以保证其他任何方法都不会看到对象处于不一致的状态中。这种观点是正确的,但是它并没有说明同步的全部意义。如果没有同步,一个线程的变化就不能被其他线程看到。同步不仅可以阻止一个线程看到对象处于不一致的状态中(即原子性),它还可以保证进入同步方法或者同步代码块的每个线程,都看到由同一个锁保护的之前所有的修改结果(即可见性)。

我的理解,同步 = 原子性 + 可见性

synchronized就是同步的代名词,它具有原子性与可见性。而volatile只具有可见性,但不具有原子性。可见性其实说的就是在读之前与写之后都与主内同步,除了可见性外,volatile还严禁语义重排:“禁止reorder任意两个volatile字段或者volatile变量,并且同时严格限制(尽管没有禁止)reorder volatile字段(或变量)周围的非volatile字段(或变量)。”

Java语言规范保证或写是一个变量是原子的(即数据的读写是不可分割的。注,不可分割的操作并不意味“多线程安全”),除非这个变量的类型为long或double[JLS 17.4.7]。换句话说,读取一个非long或double类型的变量,可以保证返回值是某个线程完整保存在该变量中的值(即要么读取还没有修改的值,要么读取到某线程修改完后的值,但决不会读到另一线程对变量的一半或一部分修改后的值,如一个int型变量,某线修改该变量的前16位后,被另一线程读到,这是不可能的;而long或double类型的变量就完全有可能这样,读到的是另一线程写入的高32位,而低32位还是原来值),即使用多个线程在没有同步的情况下并发地修改这个变量也是如此。

你可能听说过,为了提高性能,在读或写原子数据的时候,应该避免使用同步。这个建议是非常危险而错误的。虽然语言规范保证了线程在读取原子数据的时候,不会看到任意的数值(严格的说是完整的值,即不会读取还未修改完成的值),但是它并不保证一个线程写入的值对于另一个线程将是可见的(即另一线程修改完后,其他线程有可能将永远读不到这个修改后的值)。为了在线程之间进行可靠的通信(需要靠可见性来保证),也为了互斥访问(需要原子性来保证),同步(需要可见性和原子性来保证)是必要的。这归因于Java语言规范中内存模型,它规定了一个线程所做的变化何时以及如何让其他线程可见[JLS 17]。

如果对共享的可变数据的访问不能同步,其后果将非常可怕,即使这个变量是原子可读写的。考虑下面这个阻止一个线程妨碍另一个线程的任务。由于boolean域的读和写操作都是原子的,程序员在访问这个域的时候不再使用同步,这是错误的做法:

。如果第二个线程在第一个线程读取旧值和写回新值期间读取这个域,第二个线程就会与第一个线程一起看到同一个值,并返回相同的序列号。这就是“安全性失败”:这个程序会计算出错误的结果。

修正generateSerialNumber方法的一种方法是是在它的声明中加上synchronized修饰符。这样可能确保多个调用不会交叉存在。一旦这么做,就可以且应该从nextSerialNumber中删除volatile修饰符。为了让这个方法更可靠,要用long代替int。但最好还是遵循第47条中的建议,使用类AtomicLong,它是java.util.concurrent.atomic的一部分,它比同步版本的generateSerialNumber性能上可能要更好,因为atomic包使用了非锁定的线程安全技术来做到同步的,下面是使用AtomicLong修正后的版本:

条),要么压根不共享。

让一个线程在我短时间内修改一个数据对象,然后与其他线程共享,这是可以接受的,只同步共享对象引用的动作。然后其他线程没有进一步的同步也可以读取对象,只要它没有再被修改。这种对象被称作为事实上不可变的。将这种对象引用从一个线程传递到其他的线程被称作安全发布。安全发布对象引用有许多种方法:可以将它保存在静态域中,作为类初始化的一部分;可以将它保存在volatile域、final域或者通过正常锁定访问域中;或者可以将它放到并发集合中。下面是针对安全发布的例子“将 volatile 变量用于一次性安全发布”,来自XXXX:

模式 #2:一次性安全发布(one-time safe publication)

缺乏同步会导致无法实现可见性,这使得确定何时写入对象引用而不是原语值变得更加困难。在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。(这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象)。

实现安全发布对象的一种技术就是将对象引用定义为 volatile 类型。清单 3 展示了一个示例,其中后台线程在启动阶段从数据库加载一些数据。其他代码在能够利用这些数据时,在使用之前将检查这些数据是否曾经发布过。

清单 3.  volatile 变量用于一次性安全发布

public class BackgroundFloobleLoader {

public volatile Flooble theFlooble;

public void initInBackground() {

// do lots of stuff

theFlooble = new Flooble();  // this is the only write to theFlooble

}

}

public class SomeOtherClass {

public void doWork() {

while (true) {

// do some stuff…

// use the Flooble, but only if it is ready

if (floobleLoader.theFlooble != null)

doSomething(floobleLoader.theFlooble);

}

}

}

如果 theFlooble 引用不是 volatile 类型,doWork() 中的代码在解除对 theFlooble 的引用时,将会得到一个不完全构造的 Flooble。

该模式的一个必要条件是:被发布的对象必须是线程安全的,或者是有效的不可变对象(有效不可变意味着对象的状态在发布之后永远不会被修改)。volatile 类型的引用可以确保对象的发布形式的可见性,但是如果对象的状态在发布后将发生更改,那么就需要额外的同步。

总之,当多个线程共享可变数据的时候,每个读或写数据的线程都必须执行同步。如果没有同步,就无法保证一个线程所做的修改可以被另一个线程获知。未能同步共享可变数据会造成程序的“活性失败”和“安全性失败”。如果只是需要线程之间的交互通信,而不需要互斥,volatile修饰就是一种可以接受的同步形式。

>>>《Practical Java》线程拾遗<<<

如果synchronized函数抛也异常,则在异常离开这个函数前,锁会被自动释放。

不允许你将构造函数声明为synchronized,否则编译出错。原因是当两个线程并发调用同一个构造函数时,它们各自操控的是同一个class的两个不同实体对象的内存,所以没有必要。但是,如果构造器中要访问竞争共享资源的代码时,需要使用同步块来访问临界资源。

synchronized修饰的非静态函数时,锁对象为this;修饰静态函数时,锁对象为当前对象的Class对象。

需要同步的资源一定要声明成private的,不然外界直接可以访问这个临界资源了。

notifyAll和notify一样,不能指定以何种顺序通知线程。唤醒线程由JVM决定,除了保证所有等待中的线程都被唤醒之外,不做任何其他保证,线程未必以优先权顺序来接获通知。

使用wait和notifyAll线程通信机制替换轮询循环,避免不必要的性能损耗。

不要对locked object(上锁对象)的object reference重新赋值,否则会破坏同步。

不要调用stop或suspend。stop的本意是用来中止一个线程,中止线程的问题根源不在object locks,而在object的状态,当stop中止一个线程时,会释放线程持有的所有locks,但是你并不知道当时代码正在做些什么,所以会造成object处于无效状态;suspend本意是用来“暂时悬挂起一个线程”,但不安全,因为容易引起死锁,与sleep一样的是阻塞时不释放锁,但与sleep不同的是sleep是在等待一段时间后会自动唤醒,而suspend后一定需要另一线程通过调用该线程的resume方法来恢复,但此时如果调用resume方法的线程需要suspend所拥有的锁时,就会产生死锁,而sleep则安全多了,它在阻塞只在指定的时间之内,时间一到它就会恢复运行,不易引起死锁;destroy该方法最初用于破坏该线程,与suspend一样也不会释放锁,不过,该方法决不会被实现,即使要实现,它也极有可能与 suspend 一样产生死锁。

死锁实例:

相反。过多同步可能会导致性能降低、死锁,甚至不确定的行为。

为了安全性与正确性,在一个被同步的方法或者代码块中,永远不要放弃对象客户端的控制。换句话说,在一个被同步的区域内部,不要调用自己类中可被重写的方法,或者是由客户端以函数对象(如策略接口或回调接口)的形式提供的方法(见第21条)。从包含该同步区域的类的角度来看,这样的方法是外来的,这个类不知道这样的方法会做什么事,也无法控制它。根据外来方法的作用,从同步区域中调用它会导致异常、死锁或者数据正确性。

下面是一个可被观察的集合,为了简单起见,在从集合中删除元素时(remove())没有提供通知方法,只提供了在调用添加add()时才通知所有观察者,这个可被观察的集合类ObservableSet是在第16条中可重用的ForwardingSet上实现的:

后,ObservableSet的notifyElementAdded方法的for循环抛出了ConcurrentModificationException异常,因为notifyElementAdded在使用Iterator遍历集合的过程中,另一个方法added删除元素23,改变了observers的结构,所以当它准备遍历第元素时24就抛出了异常,这正是因为违反了在使用代替遍历集合时,不能通过集合本身去修改其结构的约束所致。

第二次使用以下类来进行测试:

条,这里使用CopyOnWriteArrayList来代替ArrayList,每次add与remove、set时都会重新拷贝整个底层数组,由于内部数组永远没有改动,即没有共享,所以不需要锁定:

条的指导方针。

上面是讨论正确性,下面讨论一下性能。虽然自从Java平台早期以来,同步的成本已经下降了,但更重要的是,永远不要过多同步。在这个多核时代,过多同步的实际成本并不是指获取锁所花费的CPU时间,而是指失去了并行的机会。另外潜在的开销在于,它会限制VM优化代码执行的能力。

要在一个类的内部进行同步,一个很好的理由是因为它将被大量地并发使用,而且通过执行内部细粒度的同步操作你可以获得很高的并发性。

如果一个可变的类要在并发环境中使用,应该使这个类变成线程安全的(见70)。如果经常用在并发环境中,通过内部同步,你可以获得明显比从外部锁整个对象更高的并发性(在外部同步锁的粒度粗,最细也只能到方法级别,而在内同步可以缩小同步的范围,只在需要的代码行进行同步,而不是整个方法。粗粒度锁时间长,而细粒度锁时间短,所以并发性高)。否则,如果很少在并发环境中,就不要在内部同步,让客户在必要的时候(需要并发的时候)从外部同步。在Java平台出现的早期,许多类都违背了这些指导方针,例如,StringBufer实例几乎总是被用于单个线程之中,而它们执行的却是内部同步。为此,StringBuffer基本上都都StringBuilder代替,它在Java1.5版本中是个非同步的StringBuffer。

如果你在内部同步了类,就可以使用不同的方法来实现高并发性,例如拆分锁、分离锁和非阻塞并发控制。

如果方法修改了静态域,那么你也必须同步对这个域的访问,即使这个方法通常只用于单个线程。客户要在这种方法上执行外部同步是不可能的,因为不可能保证其他不相关的客户也会执行外部同步。第66条中的generateSerialNumber方法就是这样的一个例子。(注,这段一直没有理解)

总之,为了避免死锁和数据破坏,千万不要从同步区域内部调用外来方法。更为一般地讲,要尽量限制同步域内部的工作量。当你在设计一个可变类的时候,要考虑一下它们是否应该自己完成同步操作。

68、      task(工作单元)和executor(执行机制)优先于线程(工作单元 + 执行机制)

本书第1版49条中阐述了简单的工作队列,下面是实例代码:

processItem(workItem); 条中的SetObserver),你也可以等待一个任务集合中的任何任务或者所有任务完成(利用invokeAny或者invokeAll方法),你也可以等待excecutor service优雅地完成终止(利用awaitTermination方法),你还可以在任务完成时逐个地获取这些任务的结果(利用ExecutorCompletionService),等等。

如果想让不止一个线程来处理来自这个队列的请求,只要调用一个同的静态工厂,就可创建不同的executor service,即线程池,池中的数量可以固定也可变化。

当然选择executor service是很的技巧的。如果是小程序,或者是轻载的服务器,使用Executors.newCachedThreadPool通常不错,因为它不需要配置,并且一般也能完成工作。但对于大负载的服务器来说,缓存的线程池就不是好了,因为在缓存的线程池中,被提交的任务没有排队,而是直接交给线程执行,如果服务器负载很重,会导致吞吐率下降,创建更多的线程。因此,在大负载的产品中,最好使用executors.newFixedThreadPool,它为你提供了一个包含固定线程数目的线程池。然而,如果你想更灵活,可以直接使用ThreadPoolExecutor类,这个类允许你控制线程池的几乎每个方面。

你不仅应该尽量不要编写自己的工作队列,而且应该尽量不直接使用线程,现在关键的抽象不再是Thread了,它以前可是即充当工作单元,又是执行机制。现在工作单元和执行机制是分开的。现在关键的抽象是工作单元,称作任务(task)。任务有两种:Runnable及其近亲Callable(与Runnable相似,但它会返回值)。执行任务的通用机制是executor service。如果你从任务的角度来看问题,并让一个executor service替你执行任务,在选择适当的执行策略方面就获得很的灵活性,从本质上讲,Excecutor Famework所做的工作是执行。

Executor Framework也有一个可替代java.util.Timer的东西,即ScheduledThreadPoolExecutor。虽然timer使用起来容易,但被调度的线程池executor更加灵活。timer只用一个线程来执行任务,这在面对长期运行的任务时,会影响到定时的准确性。如果timer唯一的线程抛出未被捕获的异常,timer就会终止。但被调度的线程池executor支持多个线程,并且能从抛出未受检异常的任务中恢复。

69、      并发工具优先于wait和notify

回顾第一版:永远不要在循环的外面调用wait—–

总是使用wait循环模式来调用wait方法,永远不要在循环的外面调用wait。循环被用来在等待的前后测试等待条件。下面是使用wait方法的标准模式:

synchronized(obj){

while(<等待条件>){

obj.wait();

}

… // 条件满足后开始处理

}

而不能是这样:

if(<等待条件>){

obj.wait();

}

… // 条件满足后开始处理

为什么这么做,因为当线程醒过来时,等待条件可能还是成立的。通过调用某个对象上的wait后,当线程会进入锁对象的等待池,在被唤醒后不会马上进入就绪状态,而是进入锁对象的锁池,只有再一次获取锁后,才能进入到就绪状态,也有可能就在它再一次获取锁前,等待条件被另一线程改变了,或者是在等待条件还根本还未破坏时另一线程意外或恶意的调用了notify或notifyAll。

notify唤醒一个正在等待的线程(如果这样的线程存在的话),而notifyAll唤醒所有正在等待的线程,通常,你总是应该使用notifyAll。这是合理而保守的建议,它总会产生正确的结果,因为它可以保证你将会唤醒所有需要被唤醒的线程。你可能也会唤醒基本他一些线程,但是这不会影响唾弃的正确性,这些线程醒来之后,会检查它们正在等待的条件,如果发现条件不满足,就会继续等待。

从优化的角度来看,如果处于等待状态的所有线程都在等待同一个条件,而每次只有一个线程可以从这个条件中被唤醒,那么你就应该选择调用nofiy,而不是notifyAll。即使这些条件都是真的,还是有理由使用notifyAll而不是notity,就好像把wait调用放在一个循环中,以避免在公有可访问的对象上意外或恶意的通知。与此类似,使用notifyAll代替notify可能避免来自不相关线程的意外或恶意的等等,否则的话,这样的等待会“吞掉”一个关键的通知,使真正的接收线程无限地等待下去。在第68条中WorkQueue例子中没有使用notifyAll的原因是,因为辅助线程WorkerThread在一个私有的对象queue上等待,所以这里不存在意外或者恶意地等待的危险。

关于使用notifyAll优先于notify的有一个告诫:虽然使用notifyAll不会影响正确性,但会影响性能,特别在是等待线程很多的情况下,因为所有被唤醒(唤醒后只是进行锁池状态)后的线程有可能因为等待条件被破坏而再次进入阻塞状态(等待池)(中间经过了从等待池状态中唤醒—>进入锁池状态—>获取锁—>进行等待池—>释放锁几个动作,所以很消耗性能,另外,即使没有获取到锁的,也会因引起大量线程竞争锁而影响性能),这会导致大量的上下文切换。但如果唤醒后等待条件不再被破坏的情况下是没有问题。所以本人认为在线程数量小或只有一个线程的情况下,可以使用notifyAll,因为这样即带来了可靠性,但又不太引响性能;或者是在线程数量大的情况,而一旦唤醒后等待条件不再被其他线程“很快”破坏或者根本就不可能被破坏时,也还是可以使用notifyAll的,因为此时的唤醒工作不会白作。

end—–

上面的这些仍然有效,但这些建议现在远远没有之前那么重要了,因为几乎没有理由再使用wait和notify,1.5中有更高级的并发工具来实现这些。

java.util.concurrent中更高级的工具分成三类:Executor Framework、并发集合以及同步器, Executor Framework已在第68条中简单的提到过。

并发集合为的集合接口(如List、Queue和Map)提供了高性能的并发实现。为了提供高并发性,这些实现在内部自己管理同步,困此,并发集合中不可能排除并发活动;将它锁定没有什么作用,只会使程序速度变慢。

上面提到“并发集合中不可能排除并发活动”,是说客户无法原子地对并发集合进行方法调用(如先调用它的get方法判断一个元素是否存在,如果不存在,再通地put放入不存在的元素就会有并发问题,因为可以在get后,切换到另一线程,然后再切换回来调用put),并发集合中的方法只是单个方法是原子性的,如果调用多个方法(如get与put)要求是原子的则也需要额外的同步才行,不过这些集合已经将些多个方法的调用扩展成了另一个接口,这样我们也就不需要自己同步了。例如ConcurrentMap扩展了Map接口,并添加了几个方法,如putIfAbsent(K key, V value)。

ConcurrentHahsMap除了提供卓越的并发性之外,速度也非常快。除非不得已,否则应该优先使用ConcurrentHahsMap,而不是使用Collections.synchronizedMap或者Hashtable。并发Map比老式的同步Map性能高,所以应该优先使用并发集合,而不是使用外部同步的集合。

同步器是一些使线程能够等待另一个线程的对象,允许它们协调动作,最常用的同步器是CountDownLatch和Semaphore。较不常用的是CyclicBarrier和Exchanger。

总这,直接使用wait和notify就像用“并发汇编语言”进行编程一样,而java.util.concurrent则提供了更高级语言。没有理由在新的代码中使用wait或notify,即使有,也是极少。如果你在维护使用wait和notify的代码时,务必确保始终是利用标准模式从while循环内部调用wait。一般情况下,应该使用notifyAll,而不是notify。如果使用notify,请小心,确保程序的活性。

70、      线程安全性的文档化

如果你没有在一个类的文档里描述并发的情况,使用这个类的程序员将可能缺少同步和过多同步。

一个类为了可被多个线程安全可用,必须在文档中清楚地说明它所支持的线程安全性级别,下面是些常见的安全级别:

1、  不可变类——这个类的实例是不可变的,所以,不需要外部的同步,如String、Long和BigInteger。

2、  无条件的线程安全——这个类的实例是可变的,但是这个类有着足够的内部同步,所以,它的实例可以被并发使用,无需任何外部同步。如,Random和ConcurrentHashMap。

3、  有条件的线程安全——除了有些方法为进行安全的并发使用而需要外部同步外,这种线程安全级别与无条件的线程安全相同。如Collections.synchronized包装返回的集合,它们的迭代器(iterator)要求同步,否则在迭代期间被其他线程所修改。下面是源码:

来检查这个域(数值类型默认值),而不是null。

如果你不在意是否每个线程都新计算域的值,并且域的类型为基本类型,而不是long或者double类型,就可以选择从单重检查模式的域声明中删除volatile修饰符。这种变体叫 racy single-check idiom。它加快了某些架构上的域访问,代价是增加了额外的初始化。

总之,大多数的域应该正常地进行初始化,而不是延迟初始化。如果为了达到性能目标,可以使用相应的延迟初始化方法。对于实例域,使用双重检查模式;对于静态域,则使用类延迟加载。对于可以接受重复初始化的实例域,也可考虑使用单重检查模式。下面是上面完全实例代码:

class FieldType {}

// 各种初始化模式

public class Initialization {

// 正常初始化

private final FieldType field1 = computeFieldValue();

private static FieldType computeFieldValue() {

return new FieldType();

}

// 延迟初始化模式 – 同步访问

private FieldType field2;

synchronized FieldType getField2() {

if (field2 == null)

field2 = computeFieldValue();

return field2;

}

// 对静态域使用类延迟初始化

private static class FieldHolder {

static final FieldType field = computeFieldValue();

}

static FieldType getField3() {

return FieldHolder.field;

}

// 对实例域进行双重检测延迟初始化

private volatile FieldType field4;

FieldType getField4() {

FieldType result = field4;

if (result == null) { // First check (no locking)

synchronized (this) {

result = field4;

if (result == null) // Second check (with locking)

field4 = result = computeFieldValue();

}

}

return result;

}

// 单重检查 – 会引起重复初始化

private volatile FieldType field5;

private FieldType getField5() {

FieldType result = field5;

if (result == null)

field5 = result = computeFieldValue();

return result;

}

}

72、      不依赖于线程调度器

任何依赖于线程调度器来达到正确性或者性能要求的程序,很有可能是不可移植的。

要编写健壮的、响应良好的、可移植的多线程程序应用程序,最好的办法确保可运行线程的平均数量不明显多于处理器的数量。

保持可运行线程数量尽可能少的主要方法是,让每个线程做些有意义的工作,然后等待更多有意义工作,如果线程没有在做有意义的工作,就不应该运行。

线程不应该一直处于忙等的状态,即反复地检查一个共享对象,以等待某些事情的发生。忙等会极大地增加处理器的负担,降低了同一机器上其他进行可以完成的工作量。如下面这个例子:

public void await() {//忙等,等到为零止

while (true) {

synchronized (this) {

if (count == 0)

return;

}

}

}

Thread.yield的唯一用途是在测试期间人为地增加程序的并发性,可以发现一些隐藏的Bug,这种方法曾经十分奏效,但从来不能保证一定可行。Java语言规范中,Thread.yield根本不做实质性的工作,只是将控制权返回给调用者。所应该使用Thread.sleep(1)代替Thread.yield来进行并发测试,但千万不要使用Thread.sleep(0),它会立即返回。

另外,不要人为地调整线程的优先级,线程优先级是Java平台上最不可移值的特征了。

73、      避免使用线程组

线程组初衷是作为一种安全隔离一些小程序的机制,但是它们从来没有真正履行这个承诺,它们的安全价值已经差到根本不能在Java安全模型的标准工作中提及的地步了。

除了安全性外,它们允许你同时把Thread的某些基本功能应用到一组线程上,但有时已被废弃,剩下的也很少使用了,因为它们有时并不准确。

总这,线程级并没有提供太多有用的功能,而且它们提供的许多的功能都有缺陷的。我们最好把线程组看作是一个不成功的试验,你可以忽略他们,就当不存在一样。如果你正在设计一个类需要处理线程的逻辑组,或许应该使用线程池executor。

相关推荐
python开发_常用的python模块及安装方法
adodb:我们领导推荐的数据库连接组件bsddb3:BerkeleyDB的连接组件Cheetah-1.0:我比较喜欢这个版本的cheeta…
日期:2022-11-24 点赞:878 阅读:9,082
Educational Codeforces Round 11 C. Hard Process 二分
C. Hard Process题目连接:http://www.codeforces.com/contest/660/problem/CDes…
日期:2022-11-24 点赞:807 阅读:5,556
下载Ubuntn 17.04 内核源代码
zengkefu@server1:/usr/src$ uname -aLinux server1 4.10.0-19-generic #21…
日期:2022-11-24 点赞:569 阅读:6,405
可用Active Desktop Calendar V7.86 注册码序列号
可用Active Desktop Calendar V7.86 注册码序列号Name: www.greendown.cn Code: &nb…
日期:2022-11-24 点赞:733 阅读:6,179
Android调用系统相机、自定义相机、处理大图片
Android调用系统相机和自定义相机实例本博文主要是介绍了android上使用相机进行拍照并显示的两种方式,并且由于涉及到要把拍到的照片显…
日期:2022-11-24 点赞:512 阅读:7,815
Struts的使用
一、Struts2的获取  Struts的官方网站为:http://struts.apache.org/  下载完Struts2的jar包,…
日期:2022-11-24 点赞:671 阅读:4,898