Effective Java 第二章 创建和销毁对象2

简介: Effective Java 第二章 创建和销毁对象

3. 使用私有构造方法或枚类实现Singleton属性

单例是一个仅实例化一次的类[Gamma95]。单例对象通常表示无状态对象,如函数(条目 24)或一个本质上唯一的系统组件。让一个类成为单例会使测试它的客户变得困难,因为除非实现一个作为它类型的接口,否则不可能用一个模拟实现替代单例。

有两种常见的方法来实现单例。两者都基于保持构造方法私有和导出公共静态成员以提供对唯一实例的访问。在第一种方法中,成员是final修饰的属性:

// Singleton with public final field
public class Elvis {
    public static final Elvis INSTANCE = new Elvis();
    private Elvis() { ... }
    public void leaveTheBuilding() { ... }
}
复制代码
复制代码

私有构造方法只调用一次,来初始化公共静态 final Elvis.INSTANCE属性。缺少一个公共的或受保护的构造方法,保证了全局的唯一性:一旦Elvis类被初始化,一个Elvis的实例就会存在——不多也不少。客户端所做的任何事情都不能改变这一点,但需要注意的是:特权客户端可以使用AccessibleObject.setAccessible方法,以反射方式调用私有构造方法(条目 65)。如果需要防御此攻击,请修改构造函数,使其在请求创建第二个实例时抛出异常。

在第二个实现单例的方法中,公共成员是一个静态的工厂方法:

// Singleton with static factory
public class Elvis {
    private static final Elvis INSTANCE = new Elvis();
    private Elvis() { ... }
    public static Elvis getInstance() { return INSTANCE; }
    public void leaveTheBuilding() { ... }
}
复制代码
复制代码

所有对Elvis.getInstance的调用都返回相同的对象引用,并且不会创建其他的Elvis实例(与前面提到的警告相同)。

公共属性方法的主要优点是API明确表示该类是一个单例:公共静态属性是final的,所以它总是包含相同的对象引用。 第二个好处是它更简单。

静态工厂方法的一个优点是,它可以灵活地改变你的想法,无论该类是否为单例而不必更改其API。 工厂方法返回唯一的实例,但是可以修改,比如,返回调用它的每个线程的单独实例。 第二个好处是,如果你的应用程序需要它,可以编写一个泛型单例工厂(generic singleton factory )(条目30)。 使用静态工厂的最后一个优点是方法引用可以用supplier,例如Elvis :: instance等同于Supplier<Elvis>。 除非与这些优点相关的,否则公共属性方法是可取的。

创建一个使用这两种方法的单例类(第12章),仅仅将implements Serializable添加到声明中是不够的。为了维护单例的保证,声明所有的实例属性为transient,并提供一个readResolve方法(条目89)。否则,每当序列化实例被反序列化时,就会创建一个新的实例,在我们的例子中,导致出现新的Elvis实例。为了防止这种情况发生,将这个readResolve方法添加到Elvis类:

// readResolve method to preserve singleton property
private Object readResolve() {
     // Return the one true Elvis and let the garbage collector
     // take care of the Elvis impersonator.
    return INSTANCE;
}
复制代码
复制代码

实现一个单例的第三种方法是声明单一元素的枚举类:

// Enum singleton - the preferred approach
public enum Elvis {
    INSTANCE;
    public void leaveTheBuilding() { ... }
}
复制代码
复制代码

这种方式类似于公共属性方法,但更简洁,提供了免费的序列化机制,并提供了针对多个实例化的坚固保证,即使是在复杂的序列化或反射攻击的情况下。这种方法可能感觉有点不自然,但是单一元素枚举类通常是实现单例的最佳方式。注意,如果单例必须继承Enum以外的父类(尽管可以声明一个Enum来实现接口),那么就不能使用这种方法。

4. 使用私有构造方法执行非实例化

偶尔你会想写一个类,它只是一组静态方法和静态属性。 这样的类获得了不好的名声,因为有些人滥用这些类而避免以面向对象方式思考,但是它们确实有着特殊的用途。 它们可以用来按照java.lang.Mathjava.util.Arrays的方式,在基本类型的数值或数组上组织相关的方法。 它们也可以用于将静态方法(包括工厂(条目 1))分组,用于实现某个接口的对象,其方式为java.util.Collections。 (从Java 8开始,你也可以将这些方法放在接口中,假如它是你自己修改的。)最后,这样的类可以用于在final类上对方法进行分组,因为不能将它们放在子类中。

这样的实用类( utility classes)不是设计用来被实例化的:一个实例是没有意义的。然而,在没有显式构造方法的情况下,编译器提供了一个公共的、无参的默认构造方法。对于用户来说,该构造方法与其他构造方法没有什么区别。在已发布的 API中经常看到无意识的被实例的类。

**试图通过创建抽象类来强制执行非实例化是行不通的。**该类可以被子类化,子类可以被实例化。此外,它误导用户认为该类是为继承而设计的(条目 19)。不过,有一个简单的方法来确保非实例化。只有当类不包含显式构造方法时,才会生成一个默认构造方法,因此可以通过包含一个私有构造方法来实现类的非实例化:

// Noninstantiable utility class
public class UtilityClass {
    // Suppress default constructor for noninstantiability
    private UtilityClass() {
        throw new AssertionError();
    }
    ... // Remainder omitted
}
复制代码
复制代码

因为显式构造方法是私有的,所以在类之外是不可访问的。AssertionError异常不是严格要求的,但是它提供了一种保证,以防在类中意外地调用构造方法。它保证类在任何情况下都不会被实例化。这个习惯用法有点违反直觉,好像构造方法就是设计成不能调用的一样。因此,如前面所示,添加注释是种明智的做法。

这种习惯有一个副作用,阻止了类的子类化。所有的构造方法都必须显式或隐式地调用父类构造方法,而子类则没有可访问的父类构造方法来调用。

5. 使用依赖注入取代硬连接资源(hardwiring resources)

许多类依赖于一个或多个底层资源。例如,拼写检查器依赖于字典。将此类类实现为静态实用工具类并不少见(条目 4):

// Inappropriate use of static utility - inflexible & untestable!
public class SpellChecker {
    private static final Lexicon dictionary = ...;
    private SpellChecker() {} // Noninstantiable
    public static boolean isValid(String word) { ... }
    public static List<String> suggestions(String typo) { ... }
}
复制代码

同样地,将它们实现为单例也并不少见(条目 3):

// Inappropriate use of singleton - inflexible & untestable!
public class SpellChecker {
    private final Lexicon dictionary = ...;
    private SpellChecker(...) {}
    public static INSTANCE = new SpellChecker(...);
    public boolean isValid(String word) { ... }
    public List<String> suggestions(String typo) { ... }
}
复制代码

这两种方法都不令人满意,因为他们假设只有一本字典值得使用。在实际中,每种语言都有自己的字典,特殊的字典被用于特殊的词汇表。另外,使用专门的字典来进行测试也是可取的。想当然地认为一本字典就足够了,这是一厢情愿的想法。

可以通过使dictionary属性设置为非final,并添加一个方法来更改现有拼写检查器中的字典,从而让拼写检查器支持多个字典,但是在并发环境中,这是笨拙的、容易出错的和不可行的。静态实用类和单例对于那些行为被底层资源参数化的类来说是不合适的

所需要的是能够支持类的多个实例(在我们的示例中,即SpellChecker),每个实例都使用客户端所期望的资源(在我们的例子中是dictionary)。满足这一需求的简单模式是在创建新实例时将资源传递到构造方法中。这是依赖项注入(dependency injection)的一种形式:字典是拼写检查器的一个依赖项,当它创建时被注入到拼写检查器中。

// Dependency injection provides flexibility and testability
public class SpellChecker {
    private final Lexicon dictionary;
    public SpellChecker(Lexicon dictionary) {
        this.dictionary = Objects.requireNonNull(dictionary);
    }
    public boolean isValid(String word) { ... }
    public List<String> suggestions(String typo) { ... }
}
复制代码

依赖注入模式非常简单,许多程序员使用它多年而不知道它有一个名字。 虽然我们的拼写检查器的例子只有一个资源(字典),但是依赖项注入可以使用任意数量的资源和任意依赖图。 它保持了不变性(条目 17),因此多个客户端可以共享依赖对象(假设客户需要相同的底层资源)。 依赖注入同样适用于构造方法,静态工厂(条目 1)和 builder模式(条目 2)。

该模式的一个有用的变体是将资源工厂传递给构造方法。 工厂是可以重复调用以创建类型实例的对象。 这种工厂体现了工厂方法模式(Factory Method pattern )[Gamma95]。 Java 8中引入的Supplier <T>接口非常适合代表工厂。 在输入上采用Supplier<T>的方法通常应该使用有界的通配符类型( bounded wildcard type)(条目 31)约束工厂的类型参数,以允许客户端传入工厂,创建指定类型的任何子类型。 例如,下面是一个使用客户端提供的工厂生成tile的方法:

Mosaic create(Supplier<? extends Tile> tileFactory) { ... }

尽管依赖注入极大地提高了灵活性和可测试性,但它可能使大型项目变得混乱,这些项目通常包含数千个依赖项。使用依赖注入框架(如Dagger[Dagger]、Guice[Guice]或Spring[Spring])可以消除这些混乱。这些框架的使用超出了本书的范围,但是请注意,为手动依赖注入而设计的API非常适合这些框架的使用。

总之,不要使用单例或静态的实用类来实现一个类,该类依赖于一个或多个底层资源,这些资源的行为会影响类的行为,并且不让类直接创建这些资源。相反,将资源或工厂传递给构造方法(或静态工厂或builder模式)。这种称为依赖注入的实践将极大地增强类的灵活性、可重用性和可测试性。

6. 避免创建不必要的对象

在每次需要时重用一个对象而不是创建一个新的相同功能对象通常是恰当的。重用可以更快更流行。如果对象是不可变的(条目 17),它总是可以被重用。

作为一个不应该这样做的极端例子,请考虑以下语句:

String s = new String("bikini");  // DON'T DO THIS!
复制代码

语句每次执行时都会创建一个新的String实例,而这些对象的创建都不是必需的。String构造方法(“bikini”)的参数本身就是一个bikini实例,它与构造方法创建的所有对象的功能相同。如果这种用法发生在循环中,或者在频繁调用的方法中,就可以毫无必要地创建数百万个String实例。

改进后的版本如下:

String s = "bikini";
复制代码

该版本使用单个String实例,而不是每次执行时创建一个新实例。此外,它可以保证对象运行在同一虚拟机上的任何其他代码重用,而这些代码恰好包含相同的字符串字面量[JLS,3.10.5]。

通过使用静态工厂方法(static factory methods(项目1),可以避免创建不需要的对象。例如,工厂方法Boolean.valueOf(String) 比构造方法Boolean(String)更可取,后者在Java 9中被弃用。构造方法每次调用时都必须创建一个新对象,而工厂方法永远不需要这样做,在实践中也不需要。除了重用不可变对象,如果知道它们不会被修改,还可以重用可变对象。

一些对象的创建比其他对象的创建要昂贵得多。 如果要重复使用这样一个“昂贵的对象”,建议将其缓存起来以便重复使用。 不幸的是,当创建这样一个对象时并不总是很直观明显的。 假设你想写一个方法来确定一个字符串是否是一个有效的罗马数字。 以下是使用正则表达式完成此操作时最简单方法:

// Performance can be greatly improved!
static boolean isRomanNumeral(String s) {
    return s.matches("^(?=.)M*(C[MD]|D?C{0,3})"
            + "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");
}
复制代码

这个实现的问题在于它依赖于String.matches方法。 虽然String.matches是检查字符串是否与正则表达式匹配的最简单方法,但它不适合在性能临界的情况下重复使用。 问题是它在内部为正则表达式创建一个Pattern实例,并且只使用它一次,之后它就有资格进行垃圾收集。 创建Pattern实例是昂贵的,因为它需要将正则表达式编译成有限状态机(finite state machine)。

为了提高性能,作为类初始化的一部分,将正则表达式显式编译为一个Pattern实例(不可变),缓存它,并在isRomanNumeral方法的每个调用中重复使用相同的实例:

// Reusing expensive object for improved performance
public class RomanNumerals {
    private static final Pattern ROMAN = Pattern.compile(
            "^(?=.)M*(C[MD]|D?C{0,3})"
            + "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");
    static boolean isRomanNumeral(String s) {
        return ROMAN.matcher(s).matches();
    }
}
复制代码

如果经常调用,isRomanNumeral的改进版本的性能会显著提升。 在我的机器上,原始版本在输入8个字符的字符串上需要1.1微秒,而改进的版本则需要0.17微秒,速度提高了6.5倍。 性能上不仅有所改善,而且更明确清晰了。 为不可见的Pattern实例创建静态final修饰的属性,并允许给它一个名字,这个名字比正则表达式本身更具可读性。

如果包含isRomanNumeral方法的改进版本的类被初始化,但该方法从未被调用,则ROMAN属性则没必要初始化。 在第一次调用isRomanNumeral方法时,可以通过延迟初始化( lazily initializing)属性(条目 83)来排除初始化,但一般不建议这样做。 延迟初始化常常会导致实现复杂化,而性能没有可衡量的改进(条目 67)。

当一个对象是不可变的时,很明显它可以被安全地重用,但是在其他情况下,它远没有那么明显,甚至是违反直觉的。考虑适配器(adapters)的情况[Gamma95],也称为视图(views)。一个适配器是一个对象,它委托一个支持对象(backing object),提供一个可替代的接口。由于适配器没有超出其支持对象的状态,因此不需要为给定对象创建多个给定适配器的实例。

例如,Map接口的keySet方法返回Map对象的Set视图,包含Map中的所有key。 天真地说,似乎每次调用keySet都必须创建一个新的Set实例,但是对给定Map对象的keySet的每次调用都返回相同的Set实例。 尽管返回的Set实例通常是可变的,但是所有返回的对象在功能上都是相同的:当其中一个返回的对象发生变化时,所有其他对象也都变化,因为它们全部由相同的Map实例支持。 虽然创建keySet视图对象的多个实例基本上是无害的,但这是没有必要的,也没有任何好处。

另一种创建不必要的对象的方法是自动装箱(autoboxing),它允许程序员混用基本类型和包装的基本类型,根据需要自动装箱和拆箱。 自动装箱模糊不清,但不会消除基本类型和装箱基本类型之间的区别。 有微妙的语义区别和不那么细微的性能差异(条目 61)。 考虑下面的方法,它计算所有正整数的总和。 要做到这一点,程序必须使用long类型,因为int类型不足以保存所有正整数的总和:

// Hideously slow! Can you spot the object creation?
private static long sum() {
    Long sum = 0L;
    for (long i = 0; i <= Integer.MAX_VALUE; i++)
        sum += i;
    return sum;
}
复制代码

这个程序的结果是正确的,但由于写错了一个字符,运行的结果要比实际慢很多。变量sum被声明成了Long而不是long,这意味着程序构造了大约231不必要的Long实例(大约每次往Long类型的 sum变量中增加一个long类型构造的实例),把sum变量的类型由Long改为long,在我的机器上运行时间从6.3秒降低到0.59秒。这个教训很明显:优先使用基本类型而不是装箱的基本类型,也要注意无意识的自动装箱

这个条目不应该被误解为暗示对象创建是昂贵的,应该避免创建对象。 相反,使用构造方法创建和回收小的对象是非常廉价,构造方法只会做很少的显示工作,,尤其是在现代JVM实现上。 创建额外的对象以增强程序的清晰度,简单性或功能性通常是件好事。

相反,除非池中的对象非常重量级,否则通过维护自己的对象池来避免对象创建是一个坏主意。对象池的典型例子就是数据库连接。建立连接的成本非常高,因此重用这些对象是有意义的。但是,一般来说,维护自己的对象池会使代码混乱,增加内存占用,并损害性能。现代JVM实现具有高度优化的垃圾收集器,它们在轻量级对象上轻松胜过此类对象池。

这个条目的对应点是针对条目 50的防御性复制(defensive copying)。 目前的条目说:“当你应该重用一个现有的对象时,不要创建一个新的对象”,而条目 50说:“不要重复使用现有的对象,当你应该创建一个新的对象时。”请注意,重用防御性复制所要求的对象所付出的代价,要远远大于不必要地创建重复的对象。 未能在需要的情况下防御性复制会导致潜在的错误和安全漏洞;而不必要地创建对象只会影响程序的风格和性能。

7. 消除过期的对象引用

如果你从使用手动内存管理的语言(如C或c++)切换到像Java这样的带有垃圾收集机制的语言,那么作为程序员的工作就会变得容易多了,因为你的对象在使用完毕以后就自动回收了。当你第一次体验它的时候,它就像魔法一样。这很容易让人觉得你不需要考虑内存管理,但这并不完全正确。

考虑以下简单的堆栈实现:

// Can you spot the "memory leak"?
public class Stack {
    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;
    public Stack() {
        elements = new Object[DEFAULT_INITIAL_CAPACITY];
    }
    public void push(Object e) {
        ensureCapacity();
        elements[size++] = e;
    }
    public Object pop() {
        if (size == 0)
            throw new EmptyStackException();
        return elements[--size];
    }
    /**
     * Ensure space for at least one more element, roughly
     * doubling the capacity each time the array needs to grow.
     */
    private void ensureCapacity() {
        if (elements.length == size)
            elements = Arrays.copyOf(elements, 2 * size + 1);
    }
}
复制代码

这个程序没有什么明显的错误(但是对于泛型版本,请参阅条目 29)。 你可以对它进行详尽的测试,它都会成功地通过每一项测试,但有一个潜在的问题。 笼统地说,程序有一个“内存泄漏”,由于垃圾回收器的活动的增加,或内存占用的增加,静默地表现为性能下降。 在极端的情况下,这样的内存泄漏可能会导致磁盘分页( disk paging),甚至导致内存溢出(OutOfMemoryError)的失败,但是这样的故障相对较少。

那么哪里发生了内存泄漏? 如果一个栈增长后收缩,那么从栈弹出的对象不会被垃圾收集,即使使用栈的程序不再引用这些对象。 这是因为栈维护对这些对象的过期引用( obsolete references)。 过期引用简单来说就是永远不会解除的引用。 在这种情况下,元素数组“活动部分(active portion)”之外的任何引用都是过期的。 活动部分是由索引下标小于size的元素组成。

垃圾收集语言中的内存泄漏(更适当地称为无意的对象保留 unintentional object retentions)是隐蔽的。 如果无意中保留了对象引用,那么不仅这个对象排除在垃圾回收之外,而且该对象引用的任何对象也是如此。 即使只有少数对象引用被无意地保留下来,也可以阻止垃圾回收机制对许多对象的回收,这对性能产生很大的影响。

这类问题的解决方法很简单:一旦对象引用过期,将它们设置为 null。 在我们的Stack类的情景下,只要从栈中弹出,元素的引用就设置为过期。 pop方法的修正版本如下所示:

public Object pop() {
    if (size == 0)
        throw new EmptyStackException();
    Object result = elements[--size];
    elements[size] = null; // Eliminate obsolete reference
    return result;
}
复制代码

取消过期引用的另一个好处是,如果它们随后被错误地引用,程序立即抛出NullPointerException异常,而不是悄悄地做继续做错误的事情。尽可能快地发现程序中的错误是有好处的。

当程序员第一次被这个问题困扰时,他们可能会在程序结束后立即清空所有对象引用。这既不是必要的,也不是可取的;它不必要地搞乱了程序。清空对象引用应该是例外而不是规范。消除过期引用的最好方法是让包含引用的变量超出范围。如果在最近的作用域范围内定义每个变量(条目 57),这种自然就会出现这种情况。

那么什么时候应该清空一个引用呢?Stack类的哪个方面使它容易受到内存泄漏的影响?简单地说,它管理自己的内存。存储池(storage pool)由elements数组的元素组成(对象引用单元,而不是对象本身)。数组中活动部分的元素(如前面定义的)被分配,其余的元素都是空闲的。垃圾收集器没有办法知道这些;对于垃圾收集器来说,elements数组中的所有对象引用都同样有效。只有程序员知道数组的非活动部分不重要。程序员可以向垃圾收集器传达这样一个事实,一旦数组中的元素变成非活动的一部分,就可以手动清空这些元素的引用。

一般来说,当一个类自己管理内存时,程序员应该警惕内存泄漏问题。 每当一个元素被释放时,元素中包含的任何对象引用都应该被清除。

另一个常见的内存泄漏来源是缓存。一旦将对象引用放入缓存中,很容易忘记它的存在,并且在它变得无关紧要之后,仍然保留在缓存中。对于这个问题有几种解决方案。如果你正好想实现了一个缓存:只要在缓存之外存在对某个项(entry)的键(key)引用,那么这项就是明确有关联的,就可以用WeakHashMap来表示缓存;这些项在过期之后自动删除。记住,只有当缓存中某个项的生命周期是由外部引用到键(key)而不是值(value)决定时,WeakHashMap才有用。

更常见的情况是,缓存项有用的生命周期不太明确,随着时间的推移一些项变得越来越没有价值。在这种情况下,缓存应该偶尔清理掉已经废弃的项。这可以通过一个后台线程(也许是ScheduledThreadPoolExecutor)或将新的项添加到缓存时顺便清理。LinkedHashMap类使用它的removeEldestEntry方法实现了后一种方案。对于更复杂的缓存,可能直接需要使用java.lang.ref

第三个常见的内存泄漏来源是监听器和其他回调。如果你实现了一个API,其客户端注册回调,但是没有显式地撤销注册回调,除非采取一些操作,否则它们将会累积。确保回调是垃圾收集的一种方法是只存储弱引用(weak references),例如,仅将它们保存在WeakHashMap的键(key)中。

因为内存泄漏通常不会表现为明显的故障,所以它们可能会在系统中保持多年。 通常仅在仔细的代码检查或借助堆分析器( heap profiler)的调试工具才会被发现。 因此,学习如何预见这些问题,并防止这些问题发生,是非常值得的



目录
相关文章
|
1月前
|
安全 Java 编译器
Java对象一定分配在堆上吗?
本文探讨了Java对象的内存分配问题,重点介绍了JVM的逃逸分析技术及其优化策略。逃逸分析能判断对象是否会在作用域外被访问,从而决定对象是否需要分配到堆上。文章详细讲解了栈上分配、标量替换和同步消除三种优化策略,并通过示例代码说明了这些技术的应用场景。
Java对象一定分配在堆上吗?
|
2月前
|
Java API
Java 对象释放与 finalize 方法
关于 Java 对象释放的疑惑解答,以及 finalize 方法的相关知识。
51 17
|
1月前
|
存储 安全 Java
Java编程中的对象序列化与反序列化
【10月更文挑战第22天】在Java的世界里,对象序列化和反序列化是数据持久化和网络传输的关键技术。本文将带你了解如何在Java中实现对象的序列化与反序列化,并探讨其背后的原理。通过实际代码示例,我们将一步步展示如何将复杂数据结构转换为字节流,以及如何将这些字节流还原为Java对象。文章还将讨论在使用序列化时应注意的安全性问题,以确保你的应用程序既高效又安全。
|
2月前
|
存储 Java 数据管理
Java零基础-Java对象详解
【10月更文挑战第7天】Java零基础教学篇,手把手实践教学!
33 6
|
2月前
|
Oracle Java 关系型数据库
重新定义 Java 对象相等性
本文探讨了Java中的对象相等性问题,包括自反性、对称性、传递性和一致性等原则,并通过LaptopCharger类的例子展示了引用相等与内容相等的区别。文章还介绍了如何通过重写`equals`方法和使用`Comparator`接口来实现更复杂的相等度量,以满足特定的业务需求。
29 3
|
3月前
|
Java
java基础(12)抽象类以及抽象方法abstract以及ArrayList对象使用
本文介绍了Java中抽象类和抽象方法的使用,以及ArrayList的基本操作,包括添加、获取、删除元素和判断列表是否为空。
36 2
java基础(12)抽象类以及抽象方法abstract以及ArrayList对象使用
|
2月前
|
存储 Java
Java编程中的对象序列化与反序列化
【10月更文挑战第9天】在Java的世界里,对象序列化是连接数据持久化与网络通信的桥梁。本文将深入探讨Java对象序列化的机制、实践方法及反序列化过程,通过代码示例揭示其背后的原理。从基础概念到高级应用,我们将一步步揭开序列化技术的神秘面纱,让读者能够掌握这一强大工具,以应对数据存储和传输的挑战。
|
2月前
|
XML Java Maven
在 Cucumber 测试中自动将 Cucumber 数据表映射到 Java 对象
在 Cucumber 测试中自动将 Cucumber 数据表映射到 Java 对象
67 7
|
2月前
|
存储 Java 数据管理
Java零基础-Java对象详解
【10月更文挑战第3天】Java零基础教学篇,手把手实践教学!
32 1
|
2月前
|
Java 数据安全/隐私保护
java类和对象
java类和对象
26 5
下一篇
DataWorks