最终的瞬态字段和序列化

69

在Java中,是否可能拥有final transient字段,并在序列化之后将其设置为任何非默认值?我的用例是一个缓存变量,这就是为什么它是transient的原因。我还有一个习惯,即使Map字段不会被更改(即地图的内容发生更改,但对象本身保持不变),也要将其设为final。然而,这些属性似乎是相互矛盾的 - 虽然编译器允许这样的组合,但我无法在反序列化后将字段设置为除null以外的任何值。

我尝试了以下方法,但没有成功:

  • 简单的字段初始化(如示例所示):这是我通常做的事情,但初始化似乎在反序列化后不会发生。
  • 构造函数中的初始化(我认为从语义上讲与上面的方法相同);
  • readObject()中分配字段-由于该字段是final,因此无法执行此操作。

在示例中,cache仅供测试时使用。

import java.io.*;
import java.util.*;

public class test
{
    public static void main (String[] args) throws Exception
    {
        X  x = new X ();
        System.out.println (x + " " + x.cache);

        ByteArrayOutputStream  buffer = new ByteArrayOutputStream ();
        new ObjectOutputStream (buffer).writeObject (x);
        x = (X) new ObjectInputStream (new ByteArrayInputStream (buffer.toByteArray ())).readObject ();
        System.out.println (x + " " + x.cache);
    }

    public static class X implements Serializable
    {
        public final transient Map <Object, Object>  cache = new HashMap <Object, Object> ();
    }
}

输出:

test$X@1a46e30 {}
test$X@190d11 null
6个回答

36

简短的回答是“不行” - 我经常需要这个功能,但是transient(瞬态)字段不能是final(最终)。

final字段必须通过直接赋初值或在构造函数中进行初始化。在反序列化期间,这两者都不会被调用,因此transient字段的初始值必须在' readObject()'私有方法中设置,该方法在反序列化期间被调用。为了使它起作用,transient字段必须是非final的。

(严格来说,final字段只有在第一次读取时才是final的,因此有可能进行一些黑客攻击,在其被读取之前分配一个值,但对我而言,这已经过于夸张。)


谢谢。我也怀疑是这样,但我不确定自己是否漏掉了什么。 - user319799
6
你的回答“transients cannot be final”是不正确的:请查看Hibernate源代码,其中有大量使用了final transient,比如在这个链接中的SessionFactoryImpl.java文件:https://github.com/hibernate/hibernate-orm/blob/4.3.7.Final/hibernate-core/src/main/java/org/hibernate/internal/SessionFactoryImpl.java - Rudi Wijaya
22
实际上,答案是错误的。transient 字段可以是 final 的。但是,为了使其适用于除默认值(false / 0 / 0.0 / null)以外的其他内容,您需要实现不仅 readObject() 还要实现 readResolve(),或者使用反射 - Christian Hujer
@mdma,我在这里发布了一个新问题,关于transient final正常工作的问题。https://dev59.com/zJffa4cB1Zd3GeqP_bDs - user6117584
请参阅https://medium.com/@lprimak/how-to-deal-with-non-serializable-fields-in-java-correctly-4ffecad98f15,了解如何使用Lombok正确处理final transient字段。 - undefined

17
可以使用反射来更改字段的内容。适用于Java 1.5+。它能够正常工作,因为序列化是在单个线程中执行的。当另一个线程访问同一对象时,它不应更改最终字段(因为在内存模型和反射中存在奇怪的问题)。
因此,在readObject()中,您可以执行类似于以下示例的操作:
import java.lang.reflect.Field;

public class FinalTransient {

    private final transient Object a = null;

    public static void main(String... args) throws Exception {
        FinalTransient b = new FinalTransient();

        System.out.println("First: " + b.a); // e.g. after serialization

        Field f = b.getClass().getDeclaredField("a");
        f.setAccessible(true);
        f.set(b, 6); // e.g. putting back your cache

        System.out.println("Second: " + b.a); // wow: it has a value!
    }

}

记住: Final不再是最终的!


5
看起来太混乱了,我猜在这里放弃使用final会更容易 ;) - user319799
1
你也可以实现一个 TransientMap,将其标记为 final 而非 transient。然而,映射中的每个属性都必须是 transient,因此该映射不会被序列化,但在反序列化时仍然存在(并为空)。 - Pindatjuh
@doublep:实际上,反序列化是这种可能性存在的原因。这也是为什么它对于静态终态字段不起作用的原因,静态字段永远不会被(反)序列化,因此不需要这样的功能。 - Holger
1
这个可以工作,但也有一定的不安全性。我不喜欢它。所有类的实例都将具有可访问的字段,但您只需要使用一个具体实例并且仅用于单个操作。 - dmatej

16

是的,通过实现(显然鲜为人知的!)readResolve()方法可以轻松实现这一点。它允许您在反序列化后替换对象。您可以使用它来调用构造函数,以任何您想要的方式初始化替换对象。例如:

import java.io.*;
import java.util.*;

public class test {
    public static void main(String[] args) throws Exception {
        X x = new X();
        x.name = "This data will be serialized";
        x.cache.put("This data", "is transient");
        System.out.println("Before: " + x + " '" + x.name + "' " + x.cache);

        ByteArrayOutputStream buffer = new ByteArrayOutputStream();
        new ObjectOutputStream(buffer).writeObject(x);
        x = (X)new ObjectInputStream(new ByteArrayInputStream(buffer.toByteArray())).readObject();
        System.out.println("After: " + x + " '" + x.name + "' " + x.cache);
    }

    public static class X implements Serializable {
        public final transient Map<Object,Object> cache = new HashMap<>();
        public String name;

        public X() {} // normal constructor

        private X(X x) { // constructor for deserialization
            // copy the non-transient fields
            this.name = x.name;
        }

        private Object readResolve() {
            // create a new object from the deserialized one
            return new X(this);
        }
    }
}

输出 - 字符串保留,但瞬态映射被重置为空(但非空!)映射:

Before: test$X@172e0cc 'This data will be serialized' {This data=is transient}
After: test$X@490662 'This data will be serialized' {}

1
不会称之为简单。复制构造函数不是自动的,因此如果我有20个字段,其中2个是瞬态的,我需要在复制构造函数中选择性地复制18个字段。但是,这确实实现了我的目标。 - user319799
1
使用Lombok和@Builder(toBuilder = true)非常容易。 - Lenny Primak

5
解决这类问题的通用方法是使用“串行代理”(请参阅《Effective Java第二版》)。如果您需要将其应用于现有的可序列化类而不破坏序列兼容性,则需要进行一些修改。

1
你能否详细解释一下你的回答?恐怕我没有相关的书籍。 - Jules
1
@user1803551 这并不是很有帮助。在这里,答案应该提供实际的解决问题的描述,而不仅仅是指向谷歌搜索。 - Jules

4
五年后,我通过谷歌偶然发现了这篇文章,发现原来的答案并不令人满意。另一个解决方案是根本不使用反射,并使用Boann建议的技术。
它还利用了由ObjectInputStream#readFields()方法返回的GetField类,根据序列化规范,必须在私有的readObject(...)方法中调用。
该解决方案通过将检索到的字段存储在由反序列化过程创建的临时“实例”(称为FinalExample#fields)的临时瞬态字段中,使字段反序列化明确。然后反序列化所有对象字段,并调用readResolve(...):创建一个新实例,但这次使用构造函数,丢弃具有临时字段的临时实例。该实例使用GetField实例显式恢复每个字段;这是检查任何参数的地方,就像任何其他构造函数一样。如果构造函数抛出异常,则将其转换为InvalidObjectException,并且此对象的反序列化失败。

这个微基准测试确保这个解决方案不比默认的序列化/反序列化慢。实际上,在我的电脑上它更快:

Problem: 8.598s Solution: 7.818s

那么这里是代码:

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InvalidObjectException;
import java.io.ObjectInputStream;
import java.io.ObjectInputStream.GetField;
import java.io.ObjectOutputStream;
import java.io.ObjectStreamException;
import java.io.Serializable;

import org.junit.Test;

import static org.junit.Assert.*;

public class FinalSerialization {

    /**
     * Using default serialization, there are problems with transient final
     * fields. This is because internally, ObjectInputStream uses the Unsafe
     * class to create an "instance", without calling a constructor.
     */
    @Test
    public void problem() throws Exception {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos);
        WrongExample x = new WrongExample(1234);
        oos.writeObject(x);
        oos.close();
        ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
        ObjectInputStream ois = new ObjectInputStream(bais);
        WrongExample y = (WrongExample) ois.readObject();
        assertTrue(y.value == 1234);
        // Problem:
        assertFalse(y.ref != null);
        ois.close();
        baos.close();
        bais.close();
    }

    /**
     * Use the readResolve method to construct a new object with the correct
     * finals initialized. Because we now call the constructor explicitly, all
     * finals are properly set up.
     */
    @Test
    public void solution() throws Exception {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos);
        FinalExample x = new FinalExample(1234);
        oos.writeObject(x);
        oos.close();
        ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
        ObjectInputStream ois = new ObjectInputStream(bais);
        FinalExample y = (FinalExample) ois.readObject();
        assertTrue(y.ref != null);
        assertTrue(y.value == 1234);
        ois.close();
        baos.close();
        bais.close();
    }

    /**
     * The solution <em>should not</em> have worse execution time than built-in
     * deserialization.
     */
    @Test
    public void benchmark() throws Exception {
        int TRIALS = 500_000;

        long a = System.currentTimeMillis();
        for (int i = 0; i < TRIALS; i++) {
            problem();
        }
        a = System.currentTimeMillis() - a;

        long b = System.currentTimeMillis();
        for (int i = 0; i < TRIALS; i++) {
            solution();
        }
        b = System.currentTimeMillis() - b;

        System.out.println("Problem: " + a / 1000f + "s Solution: " + b / 1000f + "s");
        assertTrue(b <= a);
    }

    public static class FinalExample implements Serializable {

        private static final long serialVersionUID = 4772085863429354018L;

        public final transient Object ref = new Object();

        public final int value;

        private transient GetField fields;

        public FinalExample(int value) {
            this.value = value;
        }

        private FinalExample(GetField fields) throws IOException {
            // assign fields
            value = fields.get("value", 0);
        }

        private void readObject(ObjectInputStream stream) throws IOException,
                ClassNotFoundException {
            fields = stream.readFields();
        }

        private Object readResolve() throws ObjectStreamException {
            try {
                return new FinalExample(fields);
            } catch (IOException ex) {
                throw new InvalidObjectException(ex.getMessage());
            }
        }

    }

    public static class WrongExample implements Serializable {

        private static final long serialVersionUID = 4772085863429354018L;

        public final transient Object ref = new Object();

        public final int value;

        public WrongExample(int value) {
            this.value = value;
        }

    }

}

注意:每当该类引用另一个对象实例时,可能会泄漏序列化过程创建的临时“实例”:对象解析仅在读取所有子对象之后发生,因此子对象可能保留对临时对象的引用。类可以通过检查GetField临时字段是否为null来检查使用这种非法构造的实例。只有在它为空时,才是使用常规构造函数创建而不是通过反序列化过程创建的。

自我提示:也许五年后会有更好的解决方案。到时候再见!


2
请注意,这似乎仅适用于原始值。在尝试使用对象值进行测试后,将抛出InternalError,因为GetField对象不应从readObject方法中逃逸。因此,该答案归结为Boann的答案,并未添加任何新内容。 - Pindatjuh

0
这个问题涉及到Java默认序列化器,但我是通过搜索Gson而来到这里的。这个答案不适用于默认序列化器,但它适用于Gson和其他一些库。我不喜欢(手动)使用反射或readResolve,所以这里有另外一种方法。
在反序列化时,Gson调用默认构造函数创建对象。你可以将你的瞬态final赋值移到默认构造函数中,它们将被正确地赋值。如果你只有一个非默认构造函数来分配final变量(例如ID),那么无论你将它们分配给什么,它们都会被Gson用反射覆盖。
这意味着如果你的瞬态final赋值依赖于构造函数参数,那么这种方法就行不通了。
下面是一些示例代码:
import com.google.gson.Gson;
import java.util.HashMap;

public class Test {
    public static void main(String[] args) {

        BrokenTestObject broken = new BrokenTestObject("broken");
        FixedTestObject fixed = new FixedTestObject("fixed");

        broken = serializeAndDeserialize(broken, BrokenTestObject.class);
        fixed = serializeAndDeserialize(fixed, FixedTestObject.class);

        System.out.println(broken.id + ": " + broken.someCache);
        System.out.println(fixed.id + ": " + fixed.someCache);
    }

    public static <O> O serializeAndDeserialize(O object, Class<O> c) {
        Gson gson = new Gson();
        String json = gson.toJson(object);
        return gson.fromJson(json, c);
    }

    public static class BrokenTestObject {
        public final String id;
        public transient final HashMap<String, String> someCache = new HashMap<>();

        public BrokenTestObject(String id) {
            this.id = id;
        }
    }

    public static class FixedTestObject {
        public final String id;
        public transient final HashMap<String, String> someCache;

        public FixedTestObject(String id) {
            this.id = id;
            this.someCache = new HashMap<>();
        }

        //only used during deserialization
        private FixedTestObject() {
            this.id = null; //doesn't matter, will be overwritten during deserialization
            this.someCache = new HashMap<>();
        }
    }
}

输出:

broken: null
fixed: {}

有点帮助理解。 - mjs

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接